Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions docs/docs/Post Platform Guide/signing-for-a-company.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
sidebar_position: 12
---

# Signing for a Company

How a platform lets someone sign **for a company** and checks they're allowed to. Read [Company Delegation](/docs/W3DS%20Protocol/Company-Delegation) first for the model: directors, roles, delegations and how a chain is traced.

Everything here is in `@metastate-foundation/auth`. You need no eID wallet changes and no eVault changes.

```mermaid
sequenceDiagram
participant P as Your platform
participant W as Signer's eID wallet
participant R as Registry
participant E as Company eVault
P->>P: buildDelegatedSignRequest(…)
P->>W: w3ds://sign (session = w3ds-sign/v1 payload)
W->>P: POST redirect_uri {sessionId, signature, w3id}
P->>R: resolve signer + company, key-binding certificates
P->>E: /whois (Company record id), metaEnvelopeHistory
P->>P: verifyDelegatedSignature → valid, title, scopes, appLimits
P->>E: write DelegatedSignature (audit)
```

## 1. Ask for the signature

```ts
import { buildDelegatedSignRequest } from "@metastate-foundation/auth";

const { uri, payload } = buildDelegatedSignRequest({
onBehalfOf: companyEName, // the company
signer: userEName, // who signs
scope: "@esigner:nda", // what they sign, as a scope
delegationId, // the Delegation they rely on
documentHash: sha256Hex(fileBytes), // what exactly is signed
session: crypto.randomUUID(), // your own session id
redirectUri: `${apiBase}/api/company-signing/callback`,
title: "Head of Finance", // shown in the wallet
companyName: "Acme Ltd",
});
// Show `uri` as a QR code or deep link. Keep `payload`: it is the session.
```

The wallet shows "Signing as Head of Finance for Acme Ltd" and signs `payload` exactly as it signs any session. eNames are normalised to their `@` form for you.

**Which `delegationId`?** Delegations live in the company's eVault. A user can hold several; let them pick, or choose the one whose scopes cover your document. A Delegation's `delegateEName` is the user.

## 2. Verify it in the callback

The wallet POSTs `{ sessionId, signature, w3id, message }` to `redirect_uri`. `sessionId` is the payload.

```ts
import { verifyDelegatedSignature } from "@metastate-foundation/auth";

const result = await verifyDelegatedSignature({
payload: body.sessionId,
signature: body.signature,
registryBaseUrl: process.env.PUBLIC_REGISTRY_URL!,
platformToken, // your platform token, to read the company eVault
});

if (!result.valid) {
// result.error: invalid_payload | bad_signature | not_covered | chain_invalid
// | not_a_company | resolve_failed | history_failed | timeout
// result.detail: the precise reason, e.g. "REVOKED", "SCOPE_NOT_DELEGATED"
return reject(result);
}

result.companyEName; // the company it was signed for
result.title; // the signer's title, e.g. "NDA signer"
result.scopes; // everything this delegation covers
result.chain; // delegation ids, signer first, up to the role assignment
result.appLimits; // every link's app limits, root first
result.payload; // the parsed w3ds-sign/v1 fields (documentHash, session, …)
```

It never throws. Check that `result.payload.session` and `documentHash` match what you issued.

**Retry or refuse?**

- `resolve_failed`, `history_failed` and `timeout` mean the Registry or an eVault was unreachable, including while checking a grant further up the chain. Retry these.
- Everything else means the signature is not good for this company. Refuse it.

### App limits

`appLimits` is a list of objects, one per link that set any, root first. The model doesn't interpret them. **Your platform must satisfy every one**, which is how a child can never loosen what a parent set:

```ts
const maxAmount = Math.min(...result.appLimits.map((l) => Number(l.maxAmount ?? Infinity)));
if (invoice.amount > maxAmount) return reject("over the delegated amount");
```

## 3. Record it

After accepting, write a **DelegatedSignature** record into the company's eVault, for the company's audit trail:

```ts
await evault.createMetaEnvelope(companyEName, {
ontology: DELEGATED_SIGNATURE_ONTOLOGY, // from @metastate-foundation/delegation
acl: ["*"],
payload: {
companyEName: result.companyEName,
signerEName: result.payload.signer,
delegationId: result.payload.delegationId,
title: result.title,
scope: result.payload.scope,
documentHash: result.payload.documentHash,
session: result.payload.session,
signedPayload: body.sessionId,
signature: body.signature,
platformEName: yourPlatformEName,
signedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
},
});
```

## Granting: tools that manage a company

A tool that creates companies, roles and delegations writes records that carry their grantor's signature. Use `buildGrantSignRequest`, and **pick the record id first**, because the signature is bound to it:

```ts
import { buildGrantSignRequest } from "@metastate-foundation/auth";

const recordId = `@${crypto.randomUUID()}`;
const record = {
companyEName, delegateEName: bobEName, roleId, title: "Head of Finance",
scopes: ["@esigner:nda"], mayRedelegate: true, grantedBy: danaEName,
status: "active", createdAt: now, updatedAt: now,
};
const request = await buildGrantSignRequest({
ontology: DELEGATION_ONTOLOGY, companyEName, recordId,
signerEName: danaEName, record,
redirectUri: `${apiBase}/api/grant/callback`,
message: "Make Bob Head of Finance (NDAs)",
});
// …the wallet signs request.uri and POSTs back { signature }…
await evault.updateMetaEnvelope(companyEName, recordId, { // creates it under recordId
ontology: DELEGATION_ONTOLOGY,
acl: ["*"],
payload: {
...record,
authorization: {
signerEName: request.signerEName,
signedPayload: request.payload,
signature,
signedAt: request.signedAt,
},
},
});
```

Rules your tool should follow (verifiers ignore records that break them):

- **Company:** the first version must already list `directors`, signed by one of them. Later changes must be signed by a sitting director.
- **Role:** signed by a director, and `createdBy` must be that director.
- **Delegation from a role:** signed by a director, with `grantedBy` set to that director and scopes inside the role's.
- **Re-delegation:** signed by the parent's delegate, the parent must allow re-delegation, and scopes must be inside the parent's.
- **Revoking:** write a new signed version with `status: "revoked"` and `revokedBy` set to the signer (a director or the grantor). Revoking a parent fails everything below it without touching those records, but you may also write signed revocations for them so the data reads clearly.
- **Never delete** these records. Deletion doesn't revoke anything, because verifiers read history.

## Logins

`verifyLoginSignature` refuses any session starting with `w3ds-`. If your platform verifies logins some other way, refuse those sessions too. A login session should always be the UUID you issued, never a payload someone else supplied.

## Try it

`examples/company-delegation-demo` runs all of this on the local stack (`pnpm dev:core`), with in-process wallets, as a slide-by-slide story. It covers:

- a chain Dana → Bob → Dave → Tim,
- traced signatures,
- a scope that isn't covered,
- an illegal re-delegation,
- a forged board,
- a login replay,
- a revocation.
173 changes: 173 additions & 0 deletions docs/docs/W3DS Protocol/Company-Delegation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
---
sidebar_position: 8
---

# Company Delegation

This page explains how a company signs on W3DS. A company has its own eVault. Its **directors** hand out titled **roles**, and people with a role, or a narrower slice of one, sign **for** the company. Any platform can verify such a signature by tracing it back through the company's eVault to its board.

For how a platform requests and verifies these signatures, see [Signing for a Company](/docs/Post%20Platform%20Guide/signing-for-a-company).

## The idea in one paragraph

Bob signs an NDA "for Acme" with **his own** wallet key. The signature names Acme, the delegation Bob relies on, and what he is signing. To accept it, a platform reads Acme's eVault. It checks that:

- Bob's delegation was signed by someone entitled to grant it,
- that grant goes back, hop by hop, to a role created by one of Acme's directors,
- the directors are the ones Acme's Company record was born with, or later changed to by a sitting director,
- nothing on the way has been revoked or widened.

**The eVault enforces none of this.** It stores whatever it is given, and all authority is decided by the verifier from signatures and the eVault's version history.

```mermaid
flowchart RL
T["Tim signs an NDA<br/>for Acme"] --> TD["Tim's delegation<br/>NDA signer · granted by Dave"]
TD --> DD["Dave's delegation<br/>Finance officer · granted by Bob"]
DD --> BD["Bob's delegation<br/>Head of Finance · granted by Dana"]
BD --> R["Role: Head of Finance<br/>created by Dana"]
R --> B["Company record<br/>board: Dana"]
```

## Records

All of these live in the **company's own eVault**, are public (`acl: ["*"]`) so any verifier can read them, and are written under an id the writer picks before signing.

| Record | Ontology id | Says |
|---|---|---|
| Company | `0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402` | Who the company is and its `directors[]` |
| Role | `65fd0e21-34b9-43ef-be76-c5b39727010e` | A title, the `scopes` it covers, `appLimits`, whether holders may re-delegate |
| Delegation | `0b2f15d8-c3f9-4dba-b959-5cfa11272dae` | Gives a person a role (`roleId`) or a narrower slice of a parent delegation (`parentDelegationId`) |
| Shareholding | `6382a144-5c28-450e-bf47-e37c747791c2` | A holding of shares; recorded by a director, carries no signing authority |
| DelegatedSignature | `e2736a06-176e-4004-8fda-b40b9a132669` | Audit record of a signature made for the company, written by the platform that verified it |

### Company: born with its board

- `directors` is a list of eNames.
- **Any one director** may create roles, grant delegations from a role, and revoke any delegation.
- 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.

### Role

- `title` is what people see, e.g. "Head of Finance".
- `scopes` says what holders may sign (see [Scopes](#scopes)).
- `mayRedelegate` says whether holders may pass a narrower slice on.
- `appLimits` holds platform-specific limits, e.g. `{ "maxAmount": 10000 }`, that the platform enforces itself.
- `createdBy` must be a director, and must be the one who signs.

### Delegation and re-delegation

Exactly one of `roleId` or `parentDelegationId` is set.

- **From a role:** granted (signed) by a director. `scopes` must be a subset of the role's.
- **From a parent delegation:** granted by the parent's delegate, only if the parent has `mayRedelegate: true`. `scopes` must be a subset of the parent's, and the validity window must lie within the parent's.
- `grantedBy` is the signer. Immutable after creation: `companyEName`, `delegateEName`, `roleId`, `parentDelegationId`, `grantedBy`.
- **Revoking:** a director or the grantor writes a signed version with `status: "revoked"` and `revokedBy` set to themselves. Revocation is final.

## Scopes

A scope is either:

- `ontology:<schemaId>`: records of that ontology, or
- `@<platform-eName>:<keyword>`: a document type a platform declares, e.g. `@esigner:nda`.

**Core scopes can never be delegated.** No role or delegation may contain them, and verifiers reject them:

- identity: the UserProfile and binding-document ontologies,
- the company's own authority: Company, Shareholding, Role, Delegation, DelegatedSignature,
- anything in the reserved `@w3ds` namespace (login, key and eVault management).

## Signatures

### Grants: `w3ds-grant/v1`

Every Company, Role, Delegation and Shareholding record carries an `authorization`:

```json
{
"signerEName": "@dana…",
"signedPayload": "w3ds-grant/v1\n{\"companyEName\":…,\"ontology\":…,\"recordId\":…,\"recordSha256\":…,\"signedAt\":…,\"signer\":…}",
"signature": "<P-256 signature from the signer's wallet>",
"signedAt": "2026-10-09T07:42:04.000Z"
}
```

The signed string is short whatever the record's size, and binds:

| Field | Prevents |
|---|---|
| `recordId` | Copying the authorization onto another record |
| `recordSha256` (hash of the record without `authorization`) | Editing any field after signing |
| `signedAt` | Writing an older signed state back over a newer one |
| `companyEName`, `ontology`, `signer` | Reusing it for another company, record type or signer |

The signer's wallet signs it through an ordinary [`w3ds://sign`](/docs/W3DS%20Protocol/Signing) request: the string **is** the session. The wallet needs no changes.

### Signing for the company: `w3ds-sign/v1`

A delegate signs this payload, also sent as the `w3ds://sign` session:

```text
w3ds-sign/v1
{"delegationId":"…","documentHash":"…","issuedAt":"…","onBehalfOf":"@acme…","scope":"@esigner:nda","session":"…","signer":"@tim…"}
```

It is canonical JSON with sorted keys, exactly one valid encoding, and at most 1024 characters.

### Never a login

Every payload in this model starts with `w3ds-`. Login verifiers (`verifyLoginSignature`) refuse any session with that prefix. A signature made for a company, or over a grant, can never be replayed as a login, and a login can never claim to be on someone's behalf.

## How a verifier traces a signature

1. Parse the `w3ds-sign/v1` payload, and verify the signer's signature against their Registry-certified keys.
2. Resolve the company's eVault, and take its Company record id from `/whois` (`type` must be `company`).
3. Read the Company record's history and build the **board timeline**:
- The first version must carry a board signed by one of its own directors.
- Each later change counts only if signed by a director sitting at that time.
4. Resolve each link from the signer's delegation upwards. Each one is the **latest version** that:
- is validly signed and bound to its id,
- is not dated before the previous valid version, and not after the eVault stored it (5 minutes of clock skew allowed),
- keeps its immutable fields,
- was signed by someone entitled **when it was stored**: a director for roles and role grants, the parent's delegate for re-delegations, a director or the grantor for revocations.

Unsigned, forged, copied or written-back versions are skipped as if absent. Grants made by a director remain valid after that director leaves the board.
5. Walk the chain to the role (at most 16 links). Every link must be:
- active and within its validity window,
- for the same company,
- narrowing its parent,
- granted by the parent's delegate,
- from a parent that allows re-delegation.
6. Check the payload against the chain:
- the right company, signer and delegation,
- the scope is in the chain's scopes,
- the scope is not core.

**Revocation cascades implicitly.** Revoking or narrowing a role or delegation fails the chain of everything handed on from it at the next verification. Nothing else needs to be written.

### Why a trace fails

| Code | Meaning |
|---|---|
| `NOT_FOUND` | No valid version: never granted by anyone entitled, or copied from another record |
| `REVOKED` | This link, or one above it, was revoked |
| `NOT_A_SUBSET` | Scopes wider than the parent's or role's |
| `REDELEGATION_NOT_ALLOWED` | The parent (or role) did not allow passing it on |
| `WRONG_GRANTOR` | Not granted by the parent's delegate |
| `EXPIRED` / `NOT_YET_VALID` | Outside its validity window |
| `CORE_SCOPE` | Includes a scope that can never be delegated |
| `CYCLE` / `TOO_DEEP` | Parents loop, or more than 16 links |
| `SCOPE_NOT_DELEGATED` | The chain is fine but doesn't cover what was signed |

## Limits and trade-offs

- **eVault writes are not restricted.** Anyone with write access can store records in a company's eVault. They are ignored unless properly signed, but they cost verification time.
- **History size:** a verifier reads at most 2,000 versions of any one record. Flooding a record with writes is a denial of service, never a takeover.
- **Use counts** ("sign at most N times") are not part of the model. Use validity windows, or platform-enforced `appLimits`.

## Code

- `@metastate-foundation/delegation` (`packages/delegation`): scopes, payload formats, chain evaluation, history resolution.
- `@metastate-foundation/auth` (`packages/auth`): `verifyDelegatedSignature`, `buildDelegatedSignRequest`, `buildGrantSignRequest`.
- A runnable demonstrator: `examples/company-delegation-demo`, a slide-by-slide story on the local stack.
2 changes: 2 additions & 0 deletions examples/company-delegation-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.demo/
node_modules/
44 changes: 44 additions & 0 deletions examples/company-delegation-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Company delegation demo

A one-page demonstrator of company signing delegation on the real local stack: real eVaults, real wallets (wallet-sdk, in-process), real `w3ds://sign` requests and real P-256 signatures. No phone, no editor.

Acme's board, roles and delegations are signed records in Acme's own eVault, set up from `demo.config.json`. Every signature made for Acme is checked by `verifyDelegatedSignature` against that eVault's history; the eVault itself enforces nothing.

## Run

```bash
pnpm dev:core # Registry :4321, provisioner :3001, eVault :4000
pnpm --filter company-delegation-demo dev # http://localhost:5180
```

The page is a short story in slides (← → to move). Each slide runs its step for real; the raw records are on `/dashboard.html`.

Dana founds Acme and makes Bob Head of Finance; Bob passes it to Dave, and Dave gives Tim NDAs only. Each hand-over is a Delegation record signed by the giver. Then eSigner verifies signatures by tracing each one back through Acme's eVault, hop by hop, to the board:

| Scenario | Expected |
|---|---|
| Tim signs an NDA for Acme | valid: Tim ← Dave ← Bob ← Dana |
| Tim signs an invoice | refused at Tim: Dave only gave him NDAs |
| Tim passes his badge to Mallory | refused at Mallory: Tim couldn't pass it on |
| Tim's signature replayed as a login | refused: reserved payload |
| Mallory writes herself onto the board | refused: nobody entitled granted it |
| Dana revokes Bob, Tim signs again | refused at Bob: revoked |

**Start fresh** (slide 0) clears Acme and Act 1 provisions a fresh company eVault; on the dashboard, **Start over with a new Acme** does both. Wallets are reused. Revoking Bob is permanent for that Acme, so the acts that need Tim's chain lock until you start fresh.

## Headless

```bash
pnpm --filter company-delegation-demo smoke
```

Runs setup and every scenario against the local stack and exits non-zero if any ends differently.

## Where things live

- `demo.config.json`: the company, people, roles and delegations.
- `.demo/state.json` (gitignored): generated eNames, wallet keys and record ids.
- `src/wallet.ts`: a wallet-sdk crypto adapter for Node, and the wallet's handling of a `w3ds://sign` URI (sign the session, POST to `redirect_uri`).
- `src/demo.ts`: setup and scenarios, using `buildGrantSignRequest`, `buildDelegatedSignRequest` and `verifyDelegatedSignature` from `@metastate-foundation/auth`.

Environment overrides: `REGISTRY_URL`, `PROVISIONER_URL`, `DEMO_VERIFICATION_ID`, `DEMO_PORT`.
Loading
Loading