# @kinshipvault/verify

Check a KinshipVault ID on your own website: over 18, over 21, a name, or a full identity.

Your server makes the request and checks the answer. The issuer's signature, every shared item, the
validity dates, revocation, and the holder's phone's signature over **your** request are all checked
on your side, by the same code that runs [kinshipvault.app/verify](https://kinshipvault.app/verify).
No personal data passes through KinshipVault.

Node 20 or later. No dependencies. MIT licence.

## How it works

1. Your server calls `createRequest()`. It gets a `public` half for the page and a `secret` it keeps.
2. The page shows `<kv-verify>`. On a phone whose browser can ask a wallet (Safari on iOS 26, Chrome
   on Android 14 and later) it is a button. Anywhere else it is a code the person scans with
   KinshipVault on their phone.
3. The person sees your site and exactly what you ask, and approves with Face ID or fingerprint.
4. The widget hands your page an `answer`. Your page posts it to your server, and your server calls
   `verifyResponse(secret, answer)`.

## Server

```js
const { createRequest, verifyResponse } = require('@kinshipvault/verify');

// 1. Make a request. `origin` is the exact origin of the page that shows the widget.
//    `siteKey` (see "Your site's key") lets the person's app show your site as confirmed.
//    `minimumLevel` (optional) refuses an ID checked less well than you need.
app.post('/age-check/start', async (req, res) => {
  const r = await createRequest({ ask: 'over18', origin: 'https://shop.example',
                                  siteKey: SITE_KEY, minimumLevel: 'person_verified' });
  req.session.kvSecret = r.secret;          // keep it on the server, never send it to the browser
  res.json(r.public);
});

// 4. Check the answer. Use each secret once.
app.post('/age-check/finish', async (req, res) => {
  const secret = req.session.kvSecret;
  delete req.session.kvSecret;
  const result = await verifyResponse(secret, req.body);
  if (result.ok && result.claims.age_over_18 === true) { /* let them in */ }
  res.json({ ok: result.ok, error: result.error });
});
```

`ask` is one of:

| ask | claims you get |
| --- | --- |
| `over18` | `age_over_18` |
| `over21` | `age_over_21` |
| `name` | `given_name`, `family_name`, `birth_date` |
| `full` | the name, plus `document_number`, `expiry_date`, `issuing_country` |

`minimumLevel` is the least check behind the ID you accept, lowest first:

| level | what was checked |
| --- | --- |
| `document_verified` | the document's chip was authenticated; the person was not checked |
| `person_verified` | the document and a selfie, by the identity vendor |
| `person_verified_chip` | both, and the chip agrees with the vendor |

The [issuer trust framework](https://kinshipvault.app/identity/policy/) describes each. With a minimum
set, the answer also carries `level`.

`verifyResponse` never throws for a bad answer. It resolves either way:

```js
{ ok: true, ask: 'over18', claims: { age_over_18: true }, level: 'person_verified',
  issuer: 'KinshipVault Document Signer (prod)', validUntil: '2027-10-21T00:00:00.000Z', revocation: 'valid' }
{ ok: false, ask: 'over18', error: 'declined' }
```

Common errors: `declined` (they chose not to share), `request_expired` (more than five minutes),
`level_too_low`, `response_not_for_this_page`, `device_signature_invalid` (an answer given for another
site or request), `chain_untrusted`, `expired`, `revoked`, `suspended`, `not_shared`, `revocation_unknown`.

## Your site's key

When the person scans your code with another phone, the app reads your site's address from the
request. Sign your requests and the app confirms it; without a key it shows your address as
"Not confirmed". Make a key once:

```js
const { generateSiteKey } = require('@kinshipvault/verify');
const { privateJwk, publicJwks } = await generateSiteKey();
// privateJwk: keep it secret on your server (an environment variable), pass it as siteKey.
// publicJwks: publish it, exactly, at https://shop.example/.well-known/kinshipvault-site.json
```

The app reads that file from your site itself, with no redirect, so serve it directly with status 200
(at most 16 KB). To change keys, publish the new one beside the old one, switch, then remove the old one.

Options for `verifyResponse(secret, answer, options)`:

- `requireRevocationCheck` (default `true`): refuse an answer whose revocation list could not be read.
- `fetchStatus(uri)`: your own fetcher for the revocation list (KinshipVault's lists live at
  `https://kinshipvault.app/identity/status/<n>`); `false` skips the check.
- `anchors`: the issuing authorities to trust (PEM text, base64 DER or bytes). By default only
  KinshipVault's production authority, published at
  [kinshipvault.app/identity/iaca.pem](https://kinshipvault.app/identity/iaca.pem).

## Page

```html
<script src="/vendor/kinshipvault/qr.js"></script>
<script src="/vendor/kinshipvault/widget.js"></script>
<kv-verify lang="en"></kv-verify>
<script>
  const el = document.querySelector('kv-verify');
  fetch('/age-check/start', { method: 'POST' }).then((r) => r.json()).then((pub) => { el.request = pub; });
  el.addEventListener('kv-answer', async (e) => {
    const r = await fetch('/age-check/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(e.detail) });
    // show the result
  });
  el.addEventListener('kv-error', (e) => { /* e.detail.code: cancelled, expired, relay_unreachable */ });
</script>
```

Serve `qr.js` and `widget.js` from your own site (copy them from this package). `lang` is `en`,
`fr`, `de` or `ja`; without it the page's language is used. Colours follow the CSS variables
`--kv-accent`, `--kv-on-accent`, `--kv-text`, `--kv-muted`, `--kv-border`, `--kv-radius`.

Your Content-Security-Policy must allow the relay for the code:
`connect-src https://ajdcjlgcwjqbqtblgrfk.supabase.co`.

## Security notes

- **The secret opens the answer.** Keep it on your server, bound to the visitor's session, and use it
  once.
- **Same phone is bound to your page.** The browser tells the wallet your page's origin, and the answer
  is encrypted over it. A copy of your page on another address gets an answer that does not open here.
- **Another phone is bound to your site too.** The code names your origin, and the person's phone signs
  over it. An answer someone gives to a copy of your code under another site's name does not verify on
  your server. A copy that keeps your name shows your name to the person, and with a site key the app
  confirms it: they see your site, not the page they are on.
- **The cross-device flow needs https.** `http://localhost` works for the same phone only.
- **What the relay sees.** It carries the cross-device answer encrypted to your request's key and
  cannot open it. It is held for at most five minutes and deleted when the widget takes it.
- **What is trusted.** Credentials issued by KinshipVault. IDs held in Apple Wallet or Google Wallet
  are outside this package.

## Licence

MIT. See [LICENSE](LICENSE).
