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
16 changes: 10 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,18 @@ not run after a fatal failure.
- `RegistryEntity` on a match: the registry's own record of the issuer
(`name`, `url`, `logo`, and the untouched registry entry as `raw`). Because
it comes from the registry rather than from the credential, it is the display
name to prefer. The `dcc-legacy` and `oidf` handlers fill it in;
`vc-recognition` entities are still to come. For `oidf` it is the `metadata`
of the trust anchor's subordinate statement — `name` from
name to prefer. Every registry handler fills it in. For `oidf` it is the
`metadata` of the trust anchor's subordinate statement — `name` from
`federation_entity.organization_name`, `url` from `homepage_uri`, `logo` from
`logo_uri`, and the whole verified `metadata` as `raw` — read only from a
statement that has just verified, on a cache hit as well as on a fresh fetch.
An OIDF `logo_uri` may be a `data:` URI holding the image itself rather than
a link to it, so `entity.logo` can be large, and it is cached along with the
rest of the lookup.
rest of the lookup. For `vc-recognition` it is the recognised
`credentialSubject` entry — `name`, `url` and `logo` from the entry's members
of those names, each taken only where the entry's value is a string, and the
whole entry as `raw` — read only from a recognition credential that has
verified, on a cache hit as well as on a fresh fetch.
- `payload?: unknown` on `failure` outcomes of `CheckOutcome`. Additive —
`success` already allowed one.
- `RegistryCheckPayload`, `RegistryMatch`, `RegistryReference`,
Expand Down Expand Up @@ -276,8 +279,9 @@ not run after a fatal failure.

The whole registry entry is also available, unmodified, as `entity.raw`:
for `oidf` that is the verified subordinate statement's whole `metadata`,
which is where 1.x read `issuer` from. `dcc-legacy` and `oidf` registries
populate `entity`; `vc-recognition` does not yet. The payload is on the
which is where 1.x read `issuer` from, and for `vc-recognition` it is the
recognised `credentialSubject` entry. Every registry type populates
`entity`. The payload is on the
`registry.issuer` row in `results[]` whether the check passed or failed.
- **Reading registry names out of the message.** Code that parsed the
`registry.issuer` message — "Issuer found in registry: A. 1 registries could
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,7 @@ configuration (`trustAnchorEC`) for `oidf`.

`entity` is the registry's description of the issuer, not the credential's
description of itself, so it is the display name to prefer — the credential
cannot alter it. `dcc-legacy` and `oidf` registries populate it;
`vc-recognition` matches carry no `entity` yet.
cannot alter it. Every registry type populates it.

For `oidf`, the `entity` is the `metadata` of the trust anchor's subordinate
statement: `name` from `federation_entity.organization_name`, `url` from
Expand All @@ -137,6 +136,14 @@ time. An OIDF `logo_uri` may be a `data:` URI holding the image itself rather
than a link to it, so `entity.logo` can be large, and it is cached along with
the rest of the lookup.

For `vc-recognition`, the `entity` is the recognition credential's
`credentialSubject` entry that names the issuer: `name`, `url` and `logo` from
the entry's members of those names, and the whole entry as `raw`. Each of the
three is set only where the entry's value is a string, so an entry whose `name`
is a language map carries no `entity.name` and the value stays readable through
`raw`, as does anything else the entry holds. The credential is verified before
its subject is read, on a cache hit as well as on a fresh fetch.

```typescript
const outcome = result.results.find(r => r.id === 'trust.registry.issuer')
?.outcome;
Expand Down
26 changes: 17 additions & 9 deletions docs/api/verification-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,23 @@ own name contains `, ` cannot be recovered from it. `entity` is the
registry's description of the issuer rather than the credential's
description of itself, which makes it the display name to prefer.

`dcc-legacy` and `oidf` matches carry an `entity`; `vc-recognition`
matches do not yet. For `oidf` it is the `metadata` of the trust anchor's
subordinate statement — `name` from `federation_entity.organization_name`,
`url` from `homepage_uri`, `logo` from `logo_uri`, and the whole verified
`metadata` under `raw` — read only from a statement that has just
verified, on a cache hit as well as on a fresh fetch. Note that an OIDF
`logo_uri` may be a `data:` URI carrying the image itself rather than a
link to it, so `entity.logo` can be large, and it is cached along with the
rest of the lookup.
Matches from every registry type carry an `entity`. For `oidf` it is the
`metadata` of the trust anchor's subordinate statement — `name` from
`federation_entity.organization_name`, `url` from `homepage_uri`, `logo`
from `logo_uri`, and the whole verified `metadata` under `raw` — read only
from a statement that has just verified, on a cache hit as well as on a
fresh fetch. Note that an OIDF `logo_uri` may be a `data:` URI carrying
the image itself rather than a link to it, so `entity.logo` can be large,
and it is cached along with the rest of the lookup.

For `vc-recognition` it is the recognition credential's
`credentialSubject` entry naming the issuer — `name`, `url` and `logo`
from the entry's members of those names, and the whole entry under `raw` —
read only from a credential that has verified, on a cache hit as well as
on a fresh fetch. Each of the three is set only where the entry's value is
a string, so an entry whose `name` is a language map carries no
`entity.name`; that value, and anything else the entry holds such as an
`image`, remains readable through `raw`.

The payload rides on the `registry.issuer` row in `results[]` whether the
check passed or failed, so a consumer reading registry matches does not
Expand Down
10 changes: 6 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,10 +579,12 @@ up **`RegistryCheckPayload`** — `matches` and `uncheckedRegistries`. A `Regist
`logo`, and the untouched `raw` entry). Because the lookup stops at the first registry that
matches unless run with `exhaustive`, `matches` normally holds at most one entry. The registry
check returns the payload on its outcome, on success and on failure; a handler supplies the
`entity` on its `found` result. The `dcc-legacy` and `oidf` handlers fill one in; the
`vc-recognition` handler does not yet. The OIDF entity is the verified subordinate statement's
`metadata` — read only after that statement verifies, on a cache hit as well as on a fresh
fetch — and its `logo` may be a large `data:` URI, cached with the rest of the lookup.
`entity` on its `found` result, and every built-in handler fills one in. The OIDF entity is the
verified subordinate statement's `metadata` — read only after that statement verifies, on a cache
hit as well as on a fresh fetch — and its `logo` may be a large `data:` URI, cached with the rest
of the lookup. The `vc-recognition` entity is the recognised `credentialSubject` entry, likewise
read only from a credential that has verified: `name`, `url` and `logo` come from the entry's
members of those names where they are strings, and the whole entry is `raw`.

A lookup result cached by an earlier version, in the shape that predates `matches`, is treated as
a cache miss rather than an error: the issuer is looked up again and the current shape is cached
Expand Down
69 changes: 51 additions & 18 deletions src/services/registry-handlers/vc-recognition-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { VcRecognitionEntityIdentityRegistry } from '../../types/registry.js';
import type {
RegistryEntity,
VcRecognitionEntityIdentityRegistry
} from '../../types/registry.js';
import type { HttpGetService } from '../http-get-service/http-get-service.js';
import { DEFAULT_TTL_MS, resolveTtl, ttlFromValidUntil } from './cache-ttl.js';
import type {
Expand All @@ -19,6 +22,11 @@ import type {
* recursion when the recognition VC's own issuer is also looked up
* through a recognition registry.
*
* A match carries the recognised `credentialSubject` entry as its entity.
* It is read only from a credential that has verified, on the fresh path
* and on a cache hit alike, so it is always the recognition issuer's
* signed description of the issuer.
*
* @see https://w3c.github.io/vc-recognition/
*/
export const lookupVcRecognition: RegistryHandler = async (
Expand Down Expand Up @@ -76,10 +84,39 @@ async function lookupVcRecognitionForRegistry(
credential = loaded;
}

if (!subjectContainsDid(credential, did)) {
const entry = findSubjectEntry(credential, did);
if (!entry) {
return { status: 'not-found' };
}
return { status: 'found', registryName: registry.name };
return {
status: 'found',
registryName: registry.name,
entity: entityFromEntry(entry)
};
}

/**
* The registry's record of the issuer, read from the recognised
* `credentialSubject` entry: its name, homepage and logo as the entry
* gives them, plus the whole entry, so a consumer can read fields this
* shape does not name.
*
* Only members that are strings are named, because a recognition
* credential's subject is arbitrary JSON-LD — a `name` may be a language
* map, and is then readable only through `raw`.
*/
function entityFromEntry(entry: Record<string, unknown>): RegistryEntity {
const entity: RegistryEntity = { raw: entry };
if (typeof entry.name === 'string') {
entity.name = entry.name;
}
if (typeof entry.url === 'string') {
entity.url = entry.url;
}
if (typeof entry.logo === 'string') {
entity.logo = entry.logo;
}
return entity;
}

function cacheKeyForVcRecognitionUrl(url: string): string {
Expand Down Expand Up @@ -118,24 +155,20 @@ function getIssuerId(credential: Record<string, unknown>): string | undefined {
return undefined;
}

function subjectContainsDid(
/** The `credentialSubject` entry recognising `did`, if there is one. */
function findSubjectEntry(
credential: Record<string, unknown>,
did: string
): boolean {
): Record<string, unknown> | undefined {
const subject = credential.credentialSubject;
if (subject === undefined || subject === null) {
return false;
}
if (Array.isArray(subject)) {
return subject.some(
entry =>
entry !== null &&
typeof entry === 'object' &&
(entry as { id?: unknown }).id === did
);
}
if (typeof subject === 'object') {
return (subject as { id?: unknown }).id === did;
return undefined;
}
return false;
const entries = Array.isArray(subject) ? subject : [subject];
return entries.find(
entry =>
entry !== null &&
typeof entry === 'object' &&
(entry as { id?: unknown }).id === did
) as Record<string, unknown> | undefined;
}
12 changes: 7 additions & 5 deletions src/types/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,13 @@ export interface RegistryReference {
* credential's description of itself, so it is the display name to
* prefer: the credential cannot alter it.
*
* `dcc-legacy` and `oidf` populate this; `vc-recognition` matches carry no
* entity yet. For `oidf` it comes from the `metadata` of the trust
* anchor's subordinate statement, read only after that statement has
* verified — on a cache hit as well, since the cached statement is
* re-verified on every hit.
* Every registry type populates this. For `oidf` it comes from the
* `metadata` of the trust anchor's subordinate statement, read only after
* that statement has verified — on a cache hit as well, since the cached
* statement is re-verified on every hit. For `vc-recognition` it is the
* recognised `credentialSubject` entry, read only from a recognition
* credential that has verified, on a cache hit as well as on a fresh
* fetch.
*/
export interface RegistryEntity {
/** Organisation name as the registry records it. */
Expand Down
125 changes: 123 additions & 2 deletions test/services/registry-handlers/vc-recognition-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,15 @@ describe('lookupVcRecognition', () => {
);
expect(result).toEqual({
status: 'found',
registryName: 'Recognition List'
registryName: 'Recognition List',
entity: {
name: 'Example Tech',
raw: {
id: 'did:web:university.example',
type: 'RecognizedEntity',
name: 'Example Tech'
}
}
});
});

Expand All @@ -80,7 +88,120 @@ describe('lookupVcRecognition', () => {
const result = await lookupVcRecognition('did:key:single', registry, ctx);
expect(result).toEqual({
status: 'found',
registryName: 'Recognition List'
registryName: 'Recognition List',
entity: { raw: { id: 'did:key:single', type: 'RecognizedEntity' } }
});
});

it('carries the entry url and logo when they are strings', async () => {
const vc = buildVc({
credentialSubject: [
{
id: 'did:web:university.example',
name: 'Example Tech',
url: 'https://university.example',
logo: 'https://university.example/logo.png',
image: { id: 'https://university.example/seal.png' }
}
]
});
const ctx = buildCtx({
httpGetService: FakeHttpGetService({ [listUrl]: okJsonBody(vc) })
});
const result = await lookupVcRecognition(
'did:web:university.example',
registry,
ctx
);
expect(result).toEqual({
status: 'found',
registryName: 'Recognition List',
entity: {
name: 'Example Tech',
url: 'https://university.example',
logo: 'https://university.example/logo.png',
raw: {
id: 'did:web:university.example',
name: 'Example Tech',
url: 'https://university.example',
logo: 'https://university.example/logo.png',
image: { id: 'https://university.example/seal.png' }
}
}
});
});

it('returns an entity with no name when the entry has none', async () => {
const vc = buildVc({
credentialSubject: [
{ id: 'did:web:university.example', type: 'RecognizedEntity' }
]
});
const ctx = buildCtx({
httpGetService: FakeHttpGetService({ [listUrl]: okJsonBody(vc) })
});
const result = await lookupVcRecognition(
'did:web:university.example',
registry,
ctx
);
expect(result).toEqual({
status: 'found',
registryName: 'Recognition List',
entity: {
raw: { id: 'did:web:university.example', type: 'RecognizedEntity' }
}
});
});

it('omits name, url and logo that are not strings', async () => {
const entry = {
id: 'did:web:university.example',
name: { en: 'Example Tech', fr: 'Exemple Tech' },
url: { id: 'https://university.example' },
logo: ['https://university.example/logo.png']
};
const vc = buildVc({ credentialSubject: [entry] });
const ctx = buildCtx({
httpGetService: FakeHttpGetService({ [listUrl]: okJsonBody(vc) })
});
const result = await lookupVcRecognition(
'did:web:university.example',
registry,
ctx
);
expect(result).toEqual({
status: 'found',
registryName: 'Recognition List',
entity: { raw: entry }
});
});

it('returns the same entity on a lookup served from the cache', async () => {
const vc = buildVc();
const ctx = buildCtx({
httpGetService: FakeHttpGetService({ [listUrl]: okJsonBody(vc) })
});
const first = await lookupVcRecognition(
'did:web:university.example',
registry,
ctx
);
const second = await lookupVcRecognition(
'did:web:university.example',
registry,
ctx
);
expect(second).toEqual(first);
expect(second).toMatchObject({
entity: {
name: 'Example Tech',
raw: {
id: 'did:web:university.example',
type: 'RecognizedEntity',
name: 'Example Tech'
}
}
});
});

Expand Down
Loading