diff --git a/docs/docs/W3DS Protocol/Company-Delegation.md b/docs/docs/W3DS Protocol/Company-Delegation.md index 265ca96f2..b65a52df4 100644 --- a/docs/docs/W3DS Protocol/Company-Delegation.md +++ b/docs/docs/W3DS Protocol/Company-Delegation.md @@ -48,6 +48,7 @@ All of these live in the **company's own eVault**, are public (`acl: ["*"]`) so - Only a **sitting director** may change the board. - A board only counts if the Company record's **very first version** already carries one, signed by one of its own directors. A Company record that started without directors can never acquire a board, so an existing company cannot be claimed by whoever adds directors first. - The company's eVault `/whois` reports `type: "company"` with this record as its manifest. Verifiers start there. +- The record is also the company's **legal identity**: legal name and form, registration number and jurisdiction, registered address, founding date, IBAN and statutes. Every field is covered by the director's signature in `authorization`, so changing any of them takes a director's signature: nobody else can, for instance, swap the account payers see. Details people edit day to day (a display name, branding, a visiting address) belong in a record the community's admins can write without a board signature. ### Role diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 7de803a87..1104ba1fd 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -205,6 +205,7 @@ export class GraphQLServer { "security_question", "deployment_key", "software_version", + "consent", ] as const; type ValidType = (typeof VALID_BINDING_DOCUMENT_TYPES)[number]; @@ -954,6 +955,7 @@ export class GraphQLServer { "security_question", "deployment_key", "software_version", + "consent", ] as const; type ValidType = (typeof VALID_BINDING_DOCUMENT_TYPES)[number]; diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts index b9d86f64d..103df1c3d 100644 --- a/infrastructure/evault-core/src/core/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts @@ -269,6 +269,7 @@ export const typeDefs = /* GraphQL */ ` security_question deployment_key software_version + consent } type BindingDocumentSignature { diff --git a/infrastructure/evault-core/src/core/types/binding-document.ts b/infrastructure/evault-core/src/core/types/binding-document.ts index 04f55754d..285a7aa20 100644 --- a/infrastructure/evault-core/src/core/types/binding-document.ts +++ b/infrastructure/evault-core/src/core/types/binding-document.ts @@ -6,7 +6,8 @@ export type BindingDocumentType = | "personal_parameters" | "security_question" | "deployment_key" - | "software_version"; + | "software_version" + | "consent"; export interface BindingDocumentSignature { signer: string; @@ -79,6 +80,18 @@ export interface BindingDocumentSecurityQuestionData { answerHash: string; } +/** A person's consent to one proposal in a SignatureRequest. Created by the + * requesting platform; the subject consents by counter-signing. */ +export interface BindingDocumentConsentData { + kind: "consent"; + statement: string; + requestOwnerEName: string; + requestId: string; + slotId: string; + requesterEName: string; + proposal: { ontology: string; recordId: string; recordSha256: string }; +} + export type BindingDocumentData = | BindingDocumentIdDocumentData | BindingDocumentPhotographData @@ -87,7 +100,8 @@ export type BindingDocumentData = | BindingDocumentPersonalParametersData | BindingDocumentSecurityQuestionData | BindingDocumentDeploymentKeyData - | BindingDocumentSoftwareVersionData; + | BindingDocumentSoftwareVersionData + | BindingDocumentConsentData; export interface BindingDocument { subject: string; diff --git a/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts b/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts index 75caa2167..c886652c3 100644 --- a/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts +++ b/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts @@ -173,6 +173,62 @@ describe("BindingDocumentService (integration)", () => { } }); + const consentData = { + kind: "consent" as const, + statement: "Become head of Test Org (requested by @sarah)", + requestOwnerEName: "@org", + requestId: "request-1", + slotId: "subject", + requesterEName: "@sarah", + proposal: { ontology: "0b2f15d8-c3f9-4dba-b959-5cfa11272dae", recordId: "record-1", recordSha256: "a".repeat(64) }, + }; + + it("should create a binding document with consent type", async () => { + const result = await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "consent", + data: consentData, + ownerSignature: { + signer: TEST_ENAME, + signature: computeBindingDocumentHash({ subject: "@test-user-123", type: "consent", data: consentData }), + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + expect(result.bindingDocument.type).toBe("consent"); + expect(result.bindingDocument.data).toEqual(consentData); + }); + + it("should reject consent data that doesn't name its request and proposal", async () => { + const broken = [ + { ...consentData, statement: " " }, + { ...consentData, requestOwnerEName: "org" }, + { ...consentData, slotId: "" }, + { ...consentData, proposal: { ...consentData.proposal, recordSha256: "not-a-hash" } }, + { ...consentData, proposal: undefined }, + ]; + for (const data of broken as any[]) { + await expect( + bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "consent", + data, + ownerSignature: { + signer: TEST_ENAME, + signature: computeBindingDocumentHash({ subject: "@test-user-123", type: "consent", data }), + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ), + ).rejects.toThrow("consent data is invalid"); + } + }); + it("should create a binding document with self type", async () => { const data = { kind: "self" as const, name: "Bob Jones" }; const result = await bindingDocumentService.createBindingDocument( @@ -513,6 +569,42 @@ describe("BindingDocumentService (integration)", () => { ); }); + it("should only let the subject counter-sign a consent", async () => { + const subject = "@test-user-consent"; + const data = { + kind: "consent" as const, + statement: "Become head of Test Org (requested by @sarah)", + requestOwnerEName: "@org", + requestId: "request-2", + slotId: "subject", + requesterEName: "@sarah", + proposal: { ontology: "0b2f15d8-c3f9-4dba-b959-5cfa11272dae", recordId: "record-2", recordSha256: "b".repeat(64) }, + }; + const docHash = computeBindingDocumentHash({ subject, type: "consent", data }); + const created = await bindingDocumentService.createBindingDocument( + { + subject, + type: "consent", + data, + ownerSignature: { signer: TEST_ENAME, signature: docHash, timestamp: new Date().toISOString() }, + }, + TEST_ENAME, + ); + + await expect( + bindingDocumentService.addCounterpartySignature( + { metaEnvelopeId: created.id, signature: { signer: "@stranger", signature: docHash, timestamp: new Date().toISOString() } }, + TEST_ENAME, + ), + ).rejects.toThrow(`Signer "@stranger" is not the expected counterparty "${subject}"`); + + const updated = await bindingDocumentService.addCounterpartySignature( + { metaEnvelopeId: created.id, signature: { signer: subject, signature: docHash, timestamp: new Date().toISOString() } }, + TEST_ENAME, + ); + expect(updated.signatures.map((x) => x.signer)).toEqual([TEST_ENAME, subject]); + }); + it("should throw error when binding document not found", async () => { await expect( bindingDocumentService.addCounterpartySignature( diff --git a/infrastructure/evault-core/src/services/BindingDocumentService.ts b/infrastructure/evault-core/src/services/BindingDocumentService.ts index 73f5350af..57dcfff44 100644 --- a/infrastructure/evault-core/src/services/BindingDocumentService.ts +++ b/infrastructure/evault-core/src/services/BindingDocumentService.ts @@ -24,6 +24,7 @@ import type { BindingDocumentSignature, BindingDocumentSocialConnectionData, BindingDocumentSoftwareVersionData, + BindingDocumentConsentData, BindingDocumentType, } from "../core/types/binding-document"; @@ -220,6 +221,44 @@ function validateBindingDocumentData( commitSha: d.commitSha.toLowerCase(), } as BindingDocumentSoftwareVersionData; } + case "consent": { + const p = d.proposal as Record | undefined; + const isEName = (v: unknown) => + typeof v === "string" && v.startsWith("@") && v.length > 1; + if ( + d.kind !== "consent" || + typeof d.statement !== "string" || + !d.statement.trim() || + !isEName(d.requestOwnerEName) || + typeof d.requestId !== "string" || + !d.requestId || + typeof d.slotId !== "string" || + !d.slotId || + !isEName(d.requesterEName) || + !p || + typeof p !== "object" || + typeof p.ontology !== "string" || + typeof p.recordId !== "string" || + !p.recordId || + typeof p.recordSha256 !== "string" || + !/^[0-9a-f]{64}$/.test(p.recordSha256) + ) { + throw new ValidationError("consent data is invalid"); + } + return { + kind: "consent", + statement: d.statement, + requestOwnerEName: d.requestOwnerEName as string, + requestId: d.requestId, + slotId: d.slotId, + requesterEName: d.requesterEName as string, + proposal: { + ontology: p.ontology, + recordId: p.recordId, + recordSha256: p.recordSha256, + }, + } as BindingDocumentConsentData; + } default: { const _exhaustive: never = type; throw new ValidationError( @@ -527,8 +566,11 @@ export class BindingDocumentService { ); } - // For social_connection documents the counterparty must be the subject - if (bindingDocument.type === "social_connection") { + // For social_connection and consent documents the counterparty must be the subject + if ( + bindingDocument.type === "social_connection" || + bindingDocument.type === "consent" + ) { if (input.signature.signer !== bindingDocument.subject) { throw new Error( `Signer "${input.signature.signer}" is not the expected counterparty "${bindingDocument.subject}"`, diff --git a/services/ontology/schemas/binding-document.json b/services/ontology/schemas/binding-document.json index 04b92a7cf..fa395581e 100644 --- a/services/ontology/schemas/binding-document.json +++ b/services/ontology/schemas/binding-document.json @@ -19,7 +19,8 @@ "personal_parameters", "security_question", "deployment_key", - "software_version" + "software_version", + "consent" ], "description": "The type of binding document" }, @@ -129,9 +130,89 @@ "then": { "properties": { "data": { "$ref": "#/definitions/SoftwareVersionData" } } } + }, + { + "if": { + "properties": { "type": { "const": "consent" } }, + "required": ["type"] + }, + "then": { + "properties": { "data": { "$ref": "#/definitions/ConsentData" } } + } } ], "definitions": { + "ConsentData": { + "description": "A person's consent to one specific proposal in a SignatureRequest, kept as a BindingDocument in their own vault. The platform that created the request creates the document (its own signature is the owner signature); the subject consents by adding their signature as the counterparty, which for this type must be the subject. Both signatures cover the BindingDocument's canonical form, so the consent names exactly this request, this slot and this proposal. Declining is deleting the document.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "consent", + "description": "Discriminant for consent data." + }, + "statement": { + "type": "string", + "minLength": 1, + "description": "What the subject agrees to, in words, shown by their wallet before they sign, e.g. \"Become head of Test Org (requested by @Sarah)\"." + }, + "requestOwnerEName": { + "type": "string", + "pattern": "^@[^\\s]+$", + "description": "eName of the vault holding the SignatureRequest." + }, + "requestId": { + "type": "string", + "description": "MetaEnvelope id of the SignatureRequest." + }, + "slotId": { + "type": "string", + "minLength": 1, + "description": "The request slot this consent fills." + }, + "requesterEName": { + "type": "string", + "pattern": "^@[^\\s]+$", + "description": "eName of the person who asked for the change." + }, + "proposal": { + "type": "object", + "description": "The proposal consented to, copied from the request, so the consent cannot be reused for a different change.", + "properties": { + "ontology": { + "type": "string", + "format": "uuid", + "description": "schemaId of the proposed record." + }, + "recordId": { + "type": "string", + "description": "MetaEnvelope id the proposed record will be written under." + }, + "recordSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "Hex SHA-256 of the proposed record's canonical JSON, as in the request." + } + }, + "required": [ + "ontology", + "recordId", + "recordSha256" + ], + "additionalProperties": false + } + }, + "required": [ + "kind", + "statement", + "requestOwnerEName", + "requestId", + "slotId", + "requesterEName", + "proposal" + ], + "additionalProperties": false + }, "IdDocumentData": { "type": "object", "properties": { diff --git a/services/ontology/schemas/company.json b/services/ontology/schemas/company.json index 120cea412..8f6fe105b 100644 --- a/services/ontology/schemas/company.json +++ b/services/ontology/schemas/company.json @@ -1,9 +1,9 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402", - "title": "Company", + "title": "Organization", "domain": "work", - "description": "A company's profile. The earliest Company record naming its own eVault is that eVault's manifest.", + "description": "An organisation's legal identity and board: a company, foundation, association or cooperative. The earliest record naming its own eVault is that eVault's manifest. Titled \"Company\" until 2026-10; the schemaId is unchanged.", "type": "object", "properties": { "id": { @@ -55,7 +55,8 @@ "type": "string" }, "address": { - "type": "string" + "type": "string", + "description": "Registered address: the legal seat, as registered in the jurisdiction. A visiting address is a different fact and does not belong here." }, "registrationNumber": { "type": "string", @@ -65,6 +66,25 @@ "type": "string", "description": "Jurisdiction of registration, e.g. an ISO 3166 code" }, + "legalForm": { + "type": "string", + "description": "Legal form in its jurisdiction, as registered, e.g. \"BV\", \"stichting\", \"vereniging\", \"GmbH\", \"LLC\"." + }, + "foundingDate": { + "type": "string", + "format": "date", + "description": "Date the organisation was legally founded (YYYY-MM-DD)." + }, + "iban": { + "type": "string", + "pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$", + "description": "IBAN of the account payments to the organisation go to, without spaces. It is part of the signed record, so only a director can change it, and nobody can swap the account a payer sees without a director's signature." + }, + "statutesFileUri": { + "type": "string", + "pattern": "^w3ds://file\\?id=@[^\\s/]+/[^\\s]+$", + "description": "The organisation's statutes (articles of association), as a w3ds://file URI." + }, "createdAt": { "type": "string", "format": "date-time" diff --git a/services/ontology/schemas/signatureRequest.json b/services/ontology/schemas/signatureRequest.json new file mode 100644 index 000000000..135cbe3c0 --- /dev/null +++ b/services/ontology/schemas/signatureRequest.json @@ -0,0 +1,212 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "d5874436-fb13-46b2-b0f1-f2882c261fa3", + "title": "SignatureRequest", + "domain": "governance", + "description": "A request that named people sign a proposed record before any platform writes it: a director granting someone a role (Delegation), a board change (Company), an admission or anything else that must not happen without the right people's consent. The canonical request lives in the vault of the party the proposed record belongs to (an organisation's vault for its Delegations), not in a platform database, so any platform can show it, chase the signers or complete it. A request is a document of intent: it changes nothing by itself. Two kinds of signature fill its slots: a grant (`w3ds-grant/v1`) over the proposed record itself, held here and later used as the written record's `authorization`; and a consent, which is a `consent` BindingDocument in the signer's own vault, so each person keeps the proof of what they agreed to. Once every slot is signed, a platform writes the proposed record and marks the request `executed`.", + "type": "object", + "properties": { + "ownerEName": { + "type": "string", + "pattern": "^@[^\\s]+$", + "description": "eName of the vault this request lives in, which is also the party the proposed record belongs to (e.g. the organisation)." + }, + "requesterEName": { + "type": "string", + "pattern": "^@[^\\s]+$", + "description": "eName of the person who asked for the change." + }, + "platformEName": { + "type": "string", + "pattern": "^@[^\\s]+$", + "description": "eName of the platform that created the request, which signers' wallets are sent back to." + }, + "title": { + "type": "string", + "minLength": 1, + "description": "Short human-readable summary, e.g. \"@Sarah wants @Xolani to become head\". Visible to every platform that can read the request." + }, + "message": { + "type": "string", + "description": "Optional note from the requester to the signers." + }, + "proposal": { + "type": "object", + "description": "The record that will be written once every slot is signed, exactly as it will be stored apart from any `authorization` block.", + "properties": { + "ontology": { + "type": "string", + "format": "uuid", + "description": "schemaId of the proposed record, e.g. Delegation's." + }, + "recordId": { + "type": "string", + "description": "The MetaEnvelope id the record will be written under, chosen before anyone signs (signatures name it, so they cannot be moved onto another record)." + }, + "vaultEName": { + "type": "string", + "pattern": "^@[^\\s]+$", + "description": "eName of the vault the record will be written to. Usually ownerEName." + }, + "operation": { + "type": "string", + "enum": ["create", "update"], + "description": "create: a new record under recordId. update: a new version of the existing record recordId (e.g. revoking a Delegation)." + }, + "record": { + "type": "object", + "description": "The proposed record without `authorization`. Signers sign over its hash, so it must not change once the request is created; a different proposal is a new request." + }, + "recordSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "Hex SHA-256 of the canonical JSON of `record` (keys sorted at every level, undefined dropped), as in w3ds-grant/v1." + } + }, + "required": [ + "ontology", + "recordId", + "vaultEName", + "operation", + "record", + "recordSha256" + ], + "additionalProperties": false + }, + "slots": { + "type": "array", + "minItems": 1, + "description": "The signatures the request needs. Every slot must be signed (by one of its eligible signers) for the request to be approved; one decline declines the request.", + "items": { + "type": "object", + "properties": { + "slotId": { + "type": "string", + "minLength": 1, + "description": "Stable id of the slot within this request, named by the consent BindingDocument that fills it." + }, + "capacity": { + "type": "string", + "enum": ["subject", "authority", "approver", "witness"], + "description": "Why this signature is needed. subject: the person the change concerns consents to it. authority: someone entitled to make the change grants it (for a Delegation, a director). approver: someone whose agreement the platform's rules require. witness: attests without being a party." + }, + "label": { + "type": "string", + "description": "Human-readable reason, e.g. \"the new head\" or \"a director\"." + }, + "eligibleENames": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^@[^\\s]+$" }, + "description": "Who may fill the slot; any one of them is enough (e.g. every director, for \"a director\")." + }, + "signatureKind": { + "type": "string", + "enum": ["grant", "consent"], + "description": "grant: a w3ds-grant/v1 signature over the proposed record, kept in `grant` and used as the record's `authorization` when it is written (only for records that carry one). consent: a `consent` BindingDocument in the signer's own vault, named by `bindingDocumentId`." + }, + "status": { + "type": "string", + "enum": ["pending", "signed", "declined"], + "description": "pending until the slot's signature is recorded." + }, + "signedBy": { + "type": ["string", "null"], + "pattern": "^@[^\\s]+$", + "description": "eName of the eligible signer who filled the slot." + }, + "respondedAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "grant": { + "type": ["object", "null"], + "description": "For grant slots: the signature, in the same shape as the authorization block of Role, Delegation and Company, ready to be copied onto the written record.", + "properties": { + "signerEName": { + "type": "string", + "pattern": "^@[^\\s]+$", + "description": "The signer; equals signedBy." + }, + "signedPayload": { + "type": "string", + "minLength": 1, + "description": "The exact w3ds-grant/v1 string that was signed, over proposal.record under proposal.recordId." + }, + "signature": { + "type": "string", + "minLength": 1, + "description": "The eID wallet's signature over signedPayload." + }, + "signedAt": { + "type": "string", + "format": "date-time", + "description": "The signing time named inside signedPayload." + } + }, + "required": [ + "signerEName", + "signedPayload", + "signature", + "signedAt" + ], + "additionalProperties": false + }, + "bindingDocumentId": { + "type": ["string", "null"], + "description": "For consent slots: MetaEnvelope id of the `consent` BindingDocument in the vault of the eligible signer it was created for. The slot is signed once that document carries the signer's own signature." + } + }, + "required": [ + "slotId", + "capacity", + "eligibleENames", + "signatureKind", + "status" + ], + "additionalProperties": false + } + }, + "status": { + "type": "string", + "enum": [ + "pending", + "approved", + "declined", + "withdrawn", + "expired", + "executed" + ], + "description": "pending: waiting for slots. approved: every slot signed, the record not yet written. executed: the proposed record was written under proposal.recordId. declined: a slot was declined. withdrawn: the requester (or a platform rule) cancelled it. expired: past expiresAt." + }, + "statusReason": { + "type": "string", + "description": "Why the request ended up declined, withdrawn or expired, in words." + }, + "expiresAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "After this, signatures are no longer collected." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "ownerEName", + "requesterEName", + "title", + "proposal", + "slots", + "status", + "createdAt", + "updatedAt" + ], + "additionalProperties": false +}