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
27 changes: 24 additions & 3 deletions docs/docs/Infrastructure/eVault.md
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ The following queries and mutations are preserved for backward compatibility but

### /whois

Get information about a W3ID, including key binding certificates.
Get information about a W3ID: key binding certificates, the eVault type and its manifest.

**Request**:
```http
Expand All @@ -604,21 +604,42 @@ X-ENAME: @user-a.w3id
"keyBindingCertificates": [
"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
]
],
"type": "user",
"manifest": {
"id": "@a1b2c3d4-...",
"ontology": "550e8400-e29b-41d4-a716-446655440000",
"parsed": { "ename": "@user-a.w3id", "username": "alice", "displayName": "Alice" }
}
}
```

- `w3id`: The W3ID (eName) from the request.
- `evaultId`: The eVault instance identifier (when configured via `EVAULT_ID`). Matches the `evault` value registered with the Registry.
- `keyBindingCertificates`: JWTs binding the eName to public keys.
- `type`: `user`, `group` or `company`, or `null` when nothing is known yet.
- `manifest`: The record describing who the eVault belongs to, or `null` if there is none yet. Always public via `/whois`, whatever its ACL; GraphQL reads still follow the ACL.

#### eVault manifests

The type is decided first, then the manifest is the **earliest** record of that type's ontology whose `ename` / `eName` field is the eVault's own eName:

| Type | Detected when | Manifest ontology |
|------|---------------|-------------------|
| `company` | a Company record names the vault | Company (`0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402`) |
| `group` | the vault is keyless and a Chat or GroupManifest names it | GroupManifest (`550e8400-e29b-41d4-a716-446655440003`, shared with Chat: only records with `members` and `owner` count; legacy `a8bfb7cf-3200-4b25-9ea9-ee41100f212e` as fallback) |
| `user` | otherwise, keyed or keyless | UserProfile (`550e8400-e29b-41d4-a716-446655440000`) |

- Pinned lazily on the first `/whois`, stored on the eVault's Neo4j `User` node (`vaultType`, `manifestId`), and never changed afterwards. If the pinned record is deleted, the next `/whois` re-resolves it.
- A group with only a Chat gets a GroupManifest built from it once (name, members, admins including `adminIds`; owner defaults to the group's eName).

**Example**:
```bash
curl -X GET http://localhost:4000/whois \
-H "X-ENAME: @user-a.w3id"
```

**Use Case**: Platforms use this endpoint to retrieve public keys for signature verification and the eVault instance id for routing or auditing.
**Use Case**: Platforms use this endpoint to retrieve public keys for signature verification and the eVault instance id for routing or auditing, and to learn who an eVault belongs to.

### /logs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class GroupMembershipService implements GroupResolver {
* same profile syncs into several vaults, so the vault only identifies the
* subject when the record does not say so itself.
*/
private async enamesForProfileIds(ids: string[]): Promise<EName[]> {
async enamesForProfileIds(ids: string[]): Promise<EName[]> {
if (ids.length === 0) return [];

const result = await this.db.runQuery(
Expand Down
117 changes: 116 additions & 1 deletion infrastructure/evault-core/src/core/db/db.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import type {
PageInfo,
SearchMetaEnvelopesResult,
StoreMetaEnvelopeResult,
VaultConfig,
VaultType,
} from "./types";

export interface AwarenessWriteContext {
Expand Down Expand Up @@ -1720,6 +1722,96 @@ export class DbService {
);
}

/**
* Reads an eVault's type and pinned manifest from its per-eName node.
* @param eName - The eName identifier
*/
async getVaultConfig(eName: string): Promise<VaultConfig> {
if (!eName) {
throw new Error("eName is required for getting vault config");
}

const result = await this.runQueryInternal(
`MATCH (u:User { eName: $eName })
RETURN u.vaultType AS vaultType, u.manifestId AS manifestId,
u.manifestPinnedAt AS manifestPinnedAt`,
{ eName },
);

const record = result.records[0];
if (!record) {
return { vaultType: null, manifestId: null, manifestPinnedAt: null };
}
const pinnedAt = record.get("manifestPinnedAt");
return {
vaultType: record.get("vaultType") ?? null,
manifestId: record.get("manifestId") ?? null,
manifestPinnedAt:
typeof pinnedAt?.toNumber === "function"
? pinnedAt.toNumber()
: (pinnedAt ?? null),
};
}

/**
* Pins an eVault's type and manifest unless a manifest is already pinned.
* Setting the lock property first takes the node's write lock before the
* pin is read, so of concurrent callers exactly one wins and every caller
* gets back the config that actually landed.
* @param eName - The eName identifier
* @param vaultType - The type to record
* @param manifestId - The manifest MetaEnvelope id
*/
async pinManifest(
eName: string,
vaultType: VaultType,
manifestId: string,
): Promise<VaultConfig & { won: boolean }> {
if (!eName) {
throw new Error("eName is required for pinning a manifest");
}

const result = await this.runQueryInternal(
`MERGE (u:User { eName: $eName })
Comment thread
coodos marked this conversation as resolved.
ON CREATE SET u.publicKeys = []
SET u._manifestLock = true
WITH u, u.manifestId IS NULL AS free
FOREACH (_ IN CASE WHEN free THEN [1] ELSE [] END |
SET u.vaultType = $vaultType,
u.manifestId = $manifestId,
u.manifestPinnedAt = $now)
REMOVE u._manifestLock
RETURN free AS won, u.vaultType AS vaultType,
u.manifestId AS manifestId, u.manifestPinnedAt AS manifestPinnedAt`,
{ eName, vaultType, manifestId, now: Date.now() },
);

const record = result.records[0];
const pinnedAt = record.get("manifestPinnedAt");
return {
won: record.get("won") === true,
vaultType: record.get("vaultType") ?? null,
manifestId: record.get("manifestId") ?? null,
manifestPinnedAt:
typeof pinnedAt?.toNumber === "function"
? pinnedAt.toNumber()
: (pinnedAt ?? null),
};
}

/**
* Clears a pinned manifest, but only if it is still the given id, so a
* stale caller cannot clear a pin someone else just made.
*/
async clearManifest(eName: string, manifestId: string): Promise<void> {
await this.runQueryInternal(
`MATCH (u:User { eName: $eName })
WHERE u.manifestId = $manifestId
REMOVE u.manifestId, u.manifestPinnedAt`,
{ eName, manifestId },
);
}

/**
* Copies all meta-envelopes and their envelopes from this evault to a target evault instance.
* Preserves all IDs (metaEnvelope.id, envelope.id) and the eName property.
Expand Down Expand Up @@ -1847,12 +1939,35 @@ export class DbService {
// Copy User node with public keys if it exists
try {
const userResult = await this.runQueryInternal(
`MATCH (u:User { eName: $eName }) RETURN u.publicKeys AS publicKeys`,
`MATCH (u:User { eName: $eName })
RETURN u.publicKeys AS publicKeys, u.vaultType AS vaultType,
u.manifestId AS manifestId, u.manifestPinnedAt AS manifestPinnedAt`,
{ eName },
);

if (userResult.records.length > 0) {
const publicKeys = userResult.records[0].get("publicKeys");
const vaultType = userResult.records[0].get("vaultType");
if (vaultType) {
// Keyless vaults (groups) have no keys to copy but still
// carry their type and manifest.
await targetDbService.runQuery(
`MERGE (u:User { eName: $eName })
ON CREATE SET u.publicKeys = []
SET u.vaultType = $vaultType,
u.manifestId = $manifestId,
u.manifestPinnedAt = $manifestPinnedAt`,
{
eName,
vaultType,
manifestId:
userResult.records[0].get("manifestId") ?? null,
manifestPinnedAt:
userResult.records[0].get("manifestPinnedAt") ??
null,
},
);
}
if (
publicKeys &&
Array.isArray(publicKeys) &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Neo4j Migration: make User.eName unique
*
* The per-eName User node is the lock eVault manifests are pinned under, and
* a concurrent MERGE only guarantees uniqueness when a constraint backs it.
* Duplicate nodes left by earlier races are folded into one first, the old
* plain index is dropped, and the constraint (which brings its own index)
* replaces it.
*/

import type { Driver } from "neo4j-driver";

export async function createUserENameConstraint(driver: Driver): Promise<void> {
const session = driver.session();
try {
await session.run(
`MATCH (u:User)
WHERE u.eName IS NOT NULL
WITH u.eName AS eName, collect(u) AS nodes
WHERE size(nodes) > 1
WITH head(nodes) AS keep, tail(nodes) AS dupes
UNWIND dupes AS d
SET keep.publicKeys = reduce(
acc = coalesce(keep.publicKeys, []),
k IN coalesce(d.publicKeys, []) |
CASE WHEN k IN acc THEN acc ELSE acc + k END),
keep.vaultType = coalesce(keep.vaultType, d.vaultType),
keep.manifestId = coalesce(keep.manifestId, d.manifestId),
keep.manifestPinnedAt = coalesce(keep.manifestPinnedAt, d.manifestPinnedAt)
DETACH DELETE d`,
);
await session.run(`DROP INDEX user_ename_index IF EXISTS`);
await session.run(
`CREATE CONSTRAINT user_ename_unique IF NOT EXISTS FOR (u:User) REQUIRE u.eName IS UNIQUE`,
);
} finally {
await session.close();
}
}

This file was deleted.

11 changes: 11 additions & 0 deletions infrastructure/evault-core/src/core/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,14 @@ export type FindMetaEnvelopesPaginatedOptions = {
last?: number;
before?: string;
};

/** What kind of subject an eVault belongs to. */
export type VaultType = "user" | "group" | "company";

/** An eVault's type and pinned manifest, kept on its per-eName node. */
export type VaultConfig = {
vaultType: VaultType | null;
manifestId: string | null;
/** Epoch ms when the manifest was pinned. */
manifestPinnedAt: number | null;
};
34 changes: 34 additions & 0 deletions infrastructure/evault-core/src/core/http/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import type {
} from "../../services/ProvisioningService";
import { DbService } from "../db/db.service";
import { ProtectedZoneService } from "../db/protected-zone.service";
import {
ManifestService,
type ResolvedManifest,
} from "../manifest/manifest.service";
import { connectWithRetry } from "../db/retry-neo4j";
import { validatePassphraseStrength } from "../utils/passphrase";
import { getProvisionerJwk } from "../utils/provisioner-signer";
Expand Down Expand Up @@ -95,6 +99,8 @@ export async function registerHttpRoutes(
},
);

const manifestService = dbService ? new ManifestService(dbService) : null;

// Whois endpoint - returns both W3ID identifier and public key
server.get(
"/whois",
Expand All @@ -119,6 +125,21 @@ export async function registerHttpRoutes(
type: "array",
items: { type: "string" },
},
type: {
type: ["string", "null"],
enum: ["user", "group", "company", null],
},
manifest: {
type: ["object", "null"],
properties: {
id: { type: "string" },
ontology: { type: "string" },
parsed: {
type: "object",
additionalProperties: true,
},
},
},
},
},
400: {
Expand Down Expand Up @@ -237,10 +258,23 @@ export async function registerHttpRoutes(
}
}
}
// The manifest is public by definition: it is served here
// regardless of its ACL, which still governs GraphQL reads.
let resolved: ResolvedManifest = { type: null, manifest: null };
if (manifestService) {
try {
resolved = await manifestService.resolve(eName);
} catch (error) {
console.error("Error resolving eVault manifest:", error);
}
}

const result = {
w3id: eName,
evaultId,
keyBindingCertificates: keyBindingCertificates,
type: resolved.type,
manifest: resolved.manifest,
};
return result;
},
Expand Down
Loading
Loading