signet-verify 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -0
- package/README.md +117 -41
- package/dist/signet-verify.d.ts +2 -2
- package/dist/signet-verify.iife.js +1 -1
- package/dist/signet-verify.js +1 -1
- package/llms.txt +80 -0
- package/package.json +21 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.7.2 (2026-10-16)
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
- correct the published docs against the source (#49) (docs)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## 0.7.1 (2026-09-25)
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
- default cross-device relay to nos.lol (#46)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## 0.7.0 (2026-09-11)
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
- default to the Signet relay and export it as DEFAULT_RELAY_URL (#44) (verify)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## 0.6.1 (2026-09-11)
|
|
28
|
+
|
|
29
|
+
### Bug Fixes
|
|
30
|
+
|
|
31
|
+
- fail with relay-refused when the relay closes the subscription (#43) (auth)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
## 0.6.0 (2026-09-11)
|
|
36
|
+
|
|
37
|
+
### Features
|
|
38
|
+
|
|
39
|
+
- one anchor for the sign-in deadline, and tell the user about it (#42) (auth)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## 0.5.2 (2026-09-10)
|
|
44
|
+
|
|
45
|
+
### Bug Fixes
|
|
46
|
+
|
|
47
|
+
- let a restarted wait keep its original subscription window (#41) (auth)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## 0.5.1 (2026-09-08)
|
|
52
|
+
|
|
53
|
+
### Bug Fixes
|
|
54
|
+
|
|
55
|
+
- replay auth responses after mobile app switching (#39)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## 0.5.0 (2026-06-05)
|
|
60
|
+
|
|
61
|
+
### Features
|
|
62
|
+
|
|
63
|
+
- surface bunkerUri from the auth response (cross-device signer upgrade)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
## 0.3.1 (2026-05-03)
|
|
68
|
+
|
|
69
|
+
### Bug Fixes
|
|
70
|
+
|
|
71
|
+
- bump to publish 0.3.0 displayName feature to npm (#11) (release)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
## 0.3.0 (2026-05-03)
|
|
76
|
+
|
|
77
|
+
### Features
|
|
78
|
+
|
|
79
|
+
- surface displayName from AuthResponse, sanitised (#9) (auth)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
## 0.2.2 (2026-05-03)
|
|
84
|
+
|
|
85
|
+
### Features
|
|
86
|
+
|
|
87
|
+
- `SignetAuthResult.displayName?: string` — surfaces the user's persona handle when they opted to share it on the approval screen. Mirrors the `display_name` URL param delivered by the redirect-back flow. Sanitised on receipt: control + bidi characters stripped, capped at 64 chars, empty values dropped.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
## 0.2.1 (2026-04-18)
|
|
92
|
+
|
|
93
|
+
### Bug Fixes
|
|
94
|
+
|
|
95
|
+
- use published signet-protocol@^1.6.0, not file:../signet (deps)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
## 0.2.0 (2026-04-18)
|
|
100
|
+
|
|
101
|
+
### Features
|
|
102
|
+
|
|
103
|
+
- reshape signet-verify as a proper ESM npm package
|
|
104
|
+
- waitForAuthResponse SDK helper for cross-device login
|
|
105
|
+
- initial release of signet-verify — drop-in age verification SDK
|
|
106
|
+
|
|
107
|
+
### Bug Fixes
|
|
108
|
+
|
|
109
|
+
- bump esbuild to ^0.25.12 (GHSA-67mh-4wv8-2f99) (deps)
|
|
110
|
+
|
|
111
|
+
|
package/README.md
CHANGED
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
Add privacy-preserving age verification to any website. One script tag, one function call. No personal data collected.
|
|
4
4
|
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install signet-verify
|
|
9
|
+
```
|
|
10
|
+
|
|
5
11
|
## Quick Start
|
|
6
12
|
|
|
13
|
+
Script tag (the IIFE bundle exposes `window.Signet`; the version is pinned, so change `0.7.1` to upgrade):
|
|
14
|
+
|
|
7
15
|
```html
|
|
8
|
-
<
|
|
16
|
+
<button id="verify-btn">Verify your age</button>
|
|
17
|
+
<script src="https://cdn.jsdelivr.net/npm/signet-verify@0.7.1/dist/signet-verify.iife.js"></script>
|
|
9
18
|
<script>
|
|
10
19
|
document.getElementById('verify-btn').addEventListener('click', async () => {
|
|
11
20
|
const result = await Signet.verifyAge('18+');
|
|
@@ -17,17 +26,26 @@ Add privacy-preserving age verification to any website. One script tag, one func
|
|
|
17
26
|
}
|
|
18
27
|
});
|
|
19
28
|
</script>
|
|
20
|
-
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
ESM (bundler, browser only, since `verifyAge` opens a modal):
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { verifyAge } from 'signet-verify';
|
|
35
|
+
|
|
36
|
+
const result = await verifyAge('18+');
|
|
37
|
+
if (!result.verified) console.log('Not verified:', result.error);
|
|
21
38
|
```
|
|
22
39
|
|
|
23
40
|
## How It Works
|
|
24
41
|
|
|
25
42
|
1. Website calls `Signet.verifyAge('18+')`
|
|
26
|
-
2. A modal
|
|
27
|
-
3.
|
|
28
|
-
4. The
|
|
29
|
-
5.
|
|
30
|
-
|
|
43
|
+
2. A modal opens. The SDK builds a `signet-verify-request` (request ID, required age range, `relayUrl`, timestamp) but the modal shows only a truncated text placeholder, `signet:verify:` plus the first 40 characters of the base64 payload, not a rendered QR code. Those 40 characters stop before the request ID and the relay URL
|
|
44
|
+
3. The modal listens on a `BroadcastChannel` named `signet-verify-<requestId>`. It opens no WebSocket and never reads from a relay. The proof must arrive as a `signet-verify-response` message carrying that request ID on that channel, so whatever posts it has to run in the same origin and browser profile as the page
|
|
45
|
+
4. The SDK checks the credential's signature, expiry and age range, then asks the verification bot about the verifier's professional registration
|
|
46
|
+
5. Returns the result
|
|
47
|
+
|
|
48
|
+
To receive a response from another device over a relay, use `waitForAuthResponse` (see below); `verifyAge` does not do that.
|
|
31
49
|
|
|
32
50
|
## Verifier Trust
|
|
33
51
|
|
|
@@ -53,56 +71,79 @@ const result = await Signet.verifyAge('18+', { verifierCheckUrl: null });
|
|
|
53
71
|
|
|
54
72
|
## Cross-device Sign-in-with-Signet
|
|
55
73
|
|
|
56
|
-
`verifyAge`
|
|
74
|
+
`verifyAge` resolves with a `SignetVerifyResult` and reports failures in `result.error` rather than rejecting. `waitForAuthResponse` resolves with a `SignetAuthResult` on success and rejects with an `Error` whose `message` and `code` are the failure code (see below). Their `timeout` options also differ: for `verifyAge` it is how long the modal waits before resolving with `error: 'timeout'`; for `waitForAuthResponse` it is an optional, earlier give-up.
|
|
75
|
+
|
|
76
|
+
`verifyAge` is the same-browser modal flow: its response arrives on a
|
|
77
|
+
`BroadcastChannel`, never over a relay. For a cross-device sign-in,
|
|
57
78
|
a consumer minting its own challenge, opening the auth URL or QR on one
|
|
58
79
|
device, and waiting on a relay for the response published from the user's
|
|
59
|
-
phone
|
|
80
|
+
phone, use `waitForAuthResponse` directly:
|
|
60
81
|
|
|
61
82
|
```ts
|
|
62
|
-
import {
|
|
83
|
+
import { schnorr } from '@noble/curves/secp256k1.js'; // install @noble/curves yourself; the SDK does not re-export it
|
|
84
|
+
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
85
|
+
import { waitForAuthResponse, DEFAULT_RELAY_URL } from 'signet-verify';
|
|
63
86
|
|
|
64
|
-
//
|
|
87
|
+
// 1. Ephemeral session keypair. The private key must be a 32-byte Uint8Array;
|
|
88
|
+
// a hex string is rejected with `invalid-session-privkey`.
|
|
89
|
+
const sessionPrivKey = schnorr.utils.randomSecretKey();
|
|
90
|
+
const sessionPubkey = bytesToHex(schnorr.getPublicKey(sessionPrivKey));
|
|
91
|
+
|
|
92
|
+
// 2. The challenge: 32 random bytes as 64-character hex. This is the `requestId`.
|
|
93
|
+
const requestId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
|
|
94
|
+
|
|
95
|
+
// 3. Stamp this ONCE, when you mint the challenge and open the auth URL / show the QR.
|
|
65
96
|
const issuedAt = Math.floor(Date.now() / 1000);
|
|
66
97
|
|
|
98
|
+
// 4. The auth URL (render it as a QR code or a link). The parameters mirror
|
|
99
|
+
// examples/cross-device-login.html; `relay` must match `relayUrl` below.
|
|
100
|
+
const signetAppUrl = 'https://signet-app.example'; // placeholder: your Signet app's origin
|
|
101
|
+
const authUrl = signetAppUrl + '/?' + new URLSearchParams({
|
|
102
|
+
auth: '1',
|
|
103
|
+
challenge: requestId,
|
|
104
|
+
origin: location.origin,
|
|
105
|
+
name: 'My Site',
|
|
106
|
+
callback: location.origin + '/callback', // unused in relay mode, still required
|
|
107
|
+
t: String(issuedAt),
|
|
108
|
+
relay: DEFAULT_RELAY_URL,
|
|
109
|
+
sessionPubkey,
|
|
110
|
+
}).toString();
|
|
111
|
+
|
|
112
|
+
// 5. Wait for the response. Call controller.abort() to cancel.
|
|
113
|
+
const controller = new AbortController();
|
|
67
114
|
const result = await waitForAuthResponse({
|
|
68
|
-
requestId,
|
|
69
|
-
relayUrl,
|
|
70
|
-
sessionPrivKey,
|
|
115
|
+
requestId,
|
|
116
|
+
relayUrl: DEFAULT_RELAY_URL,
|
|
117
|
+
sessionPrivKey,
|
|
71
118
|
expectedOrigin: location.origin,
|
|
72
|
-
issuedAt, //
|
|
119
|
+
issuedAt, // pass on EVERY call for this sign-in, including retries
|
|
120
|
+
abortSignal: controller.signal,
|
|
73
121
|
});
|
|
74
122
|
```
|
|
75
123
|
|
|
76
124
|
### The sign-in deadline
|
|
77
125
|
|
|
78
126
|
A cross-device sign-in has exactly one deadline: `issuedAt + AUTH_FRESHNESS_WINDOW_SEC`.
|
|
79
|
-
Past that point, this library
|
|
80
|
-
to it.
|
|
127
|
+
`AUTH_FRESHNESS_WINDOW_SEC` is `300` seconds (5 minutes). Past that point, this library
|
|
128
|
+
stops listening and reports `expired`, so show a countdown to it.
|
|
81
129
|
|
|
82
130
|
```ts
|
|
83
|
-
import {
|
|
131
|
+
import { AUTH_FRESHNESS_WINDOW_SEC } from 'signet-verify';
|
|
84
132
|
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
const expiresAt = issuedAt + AUTH_FRESHNESS_WINDOW_SEC; // show a countdown to this
|
|
88
|
-
|
|
89
|
-
const result = await waitForAuthResponse({
|
|
90
|
-
requestId: challenge,
|
|
91
|
-
relayUrl: DEFAULT_RELAY_URL, // the Signet relay — must match the relay= in your auth URL
|
|
92
|
-
sessionPrivKey,
|
|
93
|
-
expectedOrigin: location.origin,
|
|
94
|
-
issuedAt, // ← pass on EVERY call for this sign-in, retries included
|
|
95
|
-
abortSignal, // ← optional; cancels the wait and closes the subscription
|
|
96
|
-
});
|
|
133
|
+
const expiresAt = issuedAt + AUTH_FRESHNESS_WINDOW_SEC; // unix seconds; count down to this
|
|
134
|
+
const secondsLeft = () => Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
|
|
97
135
|
```
|
|
98
136
|
|
|
137
|
+
The `timeout` option is in milliseconds. Leave it out when you pass `issuedAt`: the
|
|
138
|
+
wait then lasts until the deadline above.
|
|
139
|
+
|
|
99
140
|
**Pass `issuedAt` on every call, including a restart.** On mobile the page is
|
|
100
141
|
suspended the moment it hands off to the signer app. A wait restarted without the
|
|
101
142
|
anchor asks the relay only for responses newer than the restart — which silently
|
|
102
143
|
excludes the one published while you were backgrounded, and it is never asked for
|
|
103
144
|
again. This was a real, 100%-reproducible failure before 0.5.2.
|
|
104
145
|
|
|
105
|
-
**Distinguish the failures.** The rejection message is:
|
|
146
|
+
**Distinguish the failures.** The rejection is an `Error` whose message is one of:
|
|
106
147
|
|
|
107
148
|
| Code | Meaning | What to offer |
|
|
108
149
|
|---|---|---|
|
|
@@ -110,9 +151,11 @@ again. This was a real, 100%-reproducible failure before 0.5.2.
|
|
|
110
151
|
| `timeout` | The wait gave up early — an explicit shorter `timeout`, or no `issuedAt` was supplied at all | A retry, anchored to the same `issuedAt` |
|
|
111
152
|
| `denied` | The user rejected the request | Nothing; respect it |
|
|
112
153
|
| `aborted` | You aborted via `abortSignal` | Nothing |
|
|
113
|
-
| `relay-error` | The socket failed | A retry, anchored to the same `issuedAt` |
|
|
114
|
-
| `relay-
|
|
115
|
-
| `
|
|
154
|
+
| `relay-error` | The socket failed. Rejected only where there is no `document` (a non-browser runtime); in a browser the wait reconnects while the page is visible and gives up with `timeout` or `expired` | A retry, anchored to the same `issuedAt` |
|
|
155
|
+
| `relay-closed` | The socket closed. Same rule as `relay-error` | A retry, anchored to the same `issuedAt` |
|
|
156
|
+
| `relay-refused` | The relay closed the subscription — `err.reason` has its text (e.g. `auth-required`) | A relay that serves gift wraps to an unauthenticated reader. `relay.damus.io` currently does not; `nos.lol` (the default) does |
|
|
157
|
+
| `invalid-issued-at` | `issuedAt` looks like the wrong unit (e.g. milliseconds) or is too far in the future | A bug in your code, not a retry; check you're passing unix **seconds** |
|
|
158
|
+
| `invalid-request-id`, `invalid-session-privkey`, `invalid-relay-url`, `invalid-expected-origin` | The matching option failed validation before any connection was made | A bug in your code, not a retry; see the option table below |
|
|
116
159
|
|
|
117
160
|
The code is on both `err.message` and `err.code` — branch on either.
|
|
118
161
|
|
|
@@ -121,17 +164,17 @@ precedence over `issuedAt`. Prefer `issuedAt`, which derives it correctly.
|
|
|
121
164
|
|
|
122
165
|
## API
|
|
123
166
|
|
|
124
|
-
### `
|
|
167
|
+
### `verifyAge(requiredAgeRange, options?)` (`Signet.verifyAge` in the IIFE build)
|
|
125
168
|
|
|
126
|
-
Returns `Promise<SignetVerifyResult
|
|
169
|
+
Returns `Promise<SignetVerifyResult>`. Failures are reported in `result.error`, not as a rejection.
|
|
127
170
|
|
|
128
171
|
**Options:**
|
|
129
172
|
|
|
130
173
|
| Option | Type | Default | Description |
|
|
131
174
|
|--------|------|---------|-------------|
|
|
132
|
-
| `relayUrl` | `string` | `DEFAULT_RELAY_URL` (`wss://
|
|
175
|
+
| `relayUrl` | `string` | `DEFAULT_RELAY_URL` (`wss://nos.lol`) | Written into the `relayUrl` field of the request object `verifyAge` builds. `verifyAge` never connects to it, the response does not travel over it (it arrives on a `BroadcastChannel`), and the text the modal shows does not include it. The kind-1059 gift-wrap requirement under `waitForAuthResponse` therefore does not apply to this option |
|
|
133
176
|
| `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | Modal colour scheme |
|
|
134
|
-
| `timeout` | `number` | `120000` | Timeout in milliseconds |
|
|
177
|
+
| `timeout` | `number` | `120000` | Timeout in milliseconds, clamped to 5000 to 600000 |
|
|
135
178
|
| `verifierCheckUrl` | `string \| null` | `'https://verify.signet.forgesworn.dev'` | Verification bot URL. Set to `null` to skip. |
|
|
136
179
|
| `acceptUnconfirmed` | `boolean` | `false` | Accept credentials from unconfirmed verifiers |
|
|
137
180
|
|
|
@@ -156,11 +199,43 @@ Returns `Promise<SignetVerifyResult>`
|
|
|
156
199
|
|-------|---------|
|
|
157
200
|
| `'cancelled'` | User cancelled the verification |
|
|
158
201
|
| `'timeout'` | Verification timed out (default: 2 minutes) |
|
|
202
|
+
| `'invalid-age-range'` | `requiredAgeRange` is not one of the five values under Age Range Values; no modal is shown |
|
|
159
203
|
| `'invalid-credential'` | Credential signature failed verification |
|
|
204
|
+
| `'credential-expired'` | The credential's `expires` time has passed |
|
|
160
205
|
| `'age-range-not-met'` | Credential age range doesn't satisfy the requirement |
|
|
161
206
|
| `'verifier-not-confirmed'` | Verifier is not confirmed against a public register |
|
|
162
207
|
| `'verifier-check-unavailable'` | Verification bot unreachable (credential valid but verifier status unknown) |
|
|
163
208
|
|
|
209
|
+
### `waitForAuthResponse(options)` (`Signet.waitForAuthResponse` in the IIFE build)
|
|
210
|
+
|
|
211
|
+
Returns `Promise<SignetAuthResult>`. It resolves with the verified result and rejects with an `Error` on failure; `err.code` is one of the codes in the failure table under Cross-device Sign-in-with-Signet.
|
|
212
|
+
|
|
213
|
+
**Options (`WaitForAuthOptions`):**
|
|
214
|
+
|
|
215
|
+
| Option | Type | Required | Default | Description |
|
|
216
|
+
|--------|------|----------|---------|-------------|
|
|
217
|
+
| `requestId` | `string` | yes | none | The challenge sent in the auth URL: 64-character hex (32 bytes) |
|
|
218
|
+
| `relayUrl` | `string` | yes | none | The relay you subscribed on. `wss://`, or `ws://` for `localhost` and `127.0.0.1` only |
|
|
219
|
+
| `sessionPrivKey` | `Uint8Array` | yes | none | Ephemeral session private key, 32 bytes. Used to unwrap the NIP-17 response |
|
|
220
|
+
| `expectedOrigin` | `string` | yes | none | Your origin (scheme, host and optional port), verbatim. Must match the `origin` tag of the signed auth event |
|
|
221
|
+
| `issuedAt` | `number` | no, but pass it | none | Unix **seconds** when you minted the challenge. Anchors the relay query and the deadline. More than 300 seconds in the future rejects with `invalid-issued-at` |
|
|
222
|
+
| `timeout` | `number` | no | Remaining validity (`issuedAt + 300` seconds minus now) when `issuedAt` is set, otherwise `120000` | Milliseconds, clamped to 5000 to 600000. Use it only to give up earlier than the default |
|
|
223
|
+
| `since` | `number` | no | `issuedAt - 60` when `issuedAt` is set, otherwise now minus 60 | Raw relay query anchor in unix seconds. Takes precedence over `issuedAt` |
|
|
224
|
+
| `abortSignal` | `AbortSignal` | no | none | Aborts the wait and closes the subscription; rejects with `aborted` |
|
|
225
|
+
|
|
226
|
+
**Result (`SignetAuthResult`):**
|
|
227
|
+
|
|
228
|
+
| Field | Type | Description |
|
|
229
|
+
|-------|------|-------------|
|
|
230
|
+
| `pubkey` | `string` | The user's pubkey (64-character hex); a copy of `authEvent.pubkey` |
|
|
231
|
+
| `authEvent` | `SignetAuthEvent` | The verified, signed kind-21236 event: `id`, `pubkey`, `kind`, `created_at`, `tags`, `content`, `sig` |
|
|
232
|
+
| `credential` | `unknown` (optional) | Credential included in the response (Signet Login flows), passed through as received |
|
|
233
|
+
| `displayName` | `string` (optional) | Persona handle the user chose to share. Control and bidi characters are stripped and it is capped at 64 characters; still escape it when rendering |
|
|
234
|
+
| `bunkerUri` | `string` (optional) | A `bunker://` URI for upgrading to a live signer. Absent on auth-only flows |
|
|
235
|
+
| `createdAt` | `number` | Unix seconds at which the auth event was signed |
|
|
236
|
+
|
|
237
|
+
**Constants:** `AUTH_FRESHNESS_WINDOW_SEC` (`300`, seconds) and `DEFAULT_RELAY_URL` (`'wss://nos.lol'`).
|
|
238
|
+
|
|
164
239
|
## Age Range Values
|
|
165
240
|
|
|
166
241
|
| Value | Meaning |
|
|
@@ -171,11 +246,12 @@ Returns `Promise<SignetVerifyResult>`
|
|
|
171
246
|
| `'13-17'` | Ages 13–17 |
|
|
172
247
|
| `'18+'` | Adult (18 and over) |
|
|
173
248
|
|
|
249
|
+
Matching is exact: the credential's age range must equal `requiredAgeRange`. An `'18+'` credential does not satisfy `'13-17'`, and a `'13-17'` credential does not satisfy `'18+'`. Any other `requiredAgeRange` is rejected: `verifyAge` resolves at once with `error: 'invalid-age-range'` and shows no modal.
|
|
250
|
+
|
|
174
251
|
## Privacy
|
|
175
252
|
|
|
176
253
|
- No personal data is transmitted — only a cryptographic proof of age range
|
|
177
254
|
- No account creation required on the website
|
|
178
255
|
- No cookies beyond session management
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
- Compliant with GDPR, COPPA, UK Online Safety Act, France SREN law
|
|
256
|
+
- Proof verification runs in the browser. `verifyAge` makes one network request, to the verification bot, unless `verifierCheckUrl` is `null`; it opens no relay connection, and the proof reaches the modal over a same-origin `BroadcastChannel`, not the network. `waitForAuthResponse` connects only to the relay you give it
|
|
257
|
+
- The verifier check is a single GET request that sends only the verifier's public key and returns confirmed or not
|
package/dist/signet-verify.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface SignetVerifyResult {
|
|
|
29
29
|
export interface SignetVerifyOptions {
|
|
30
30
|
/** Required age range to verify (e.g., '18+', '13-17') */
|
|
31
31
|
requiredAgeRange: string;
|
|
32
|
-
/** Relay URL for cross-device communication. Default: `DEFAULT_RELAY_URL` (wss://
|
|
32
|
+
/** Relay URL for cross-device communication. Default: `DEFAULT_RELAY_URL` (wss://nos.lol). */
|
|
33
33
|
relayUrl?: string;
|
|
34
34
|
/** Callback URL for same-device flow */
|
|
35
35
|
callbackUrl?: string;
|
|
@@ -72,7 +72,7 @@ export declare const AUTH_FRESHNESS_WINDOW_SEC = 300;
|
|
|
72
72
|
* relay.damus.io, the previous default, refuses those reads and has AUTH
|
|
73
73
|
* misconfigured, so nothing can fetch a response from it.
|
|
74
74
|
*/
|
|
75
|
-
export declare const DEFAULT_RELAY_URL = "wss://
|
|
75
|
+
export declare const DEFAULT_RELAY_URL = "wss://nos.lol";
|
|
76
76
|
/** Options for waiting on a cross-device Sign-in-with-Signet response. */
|
|
77
77
|
export interface WaitForAuthOptions {
|
|
78
78
|
/** The challenge (also the `requestId`) sent in the auth URL. 64-char hex. */
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<div id="signet-qr" style="display:flex;justify-content:center;margin-bottom:24px;"></div>
|
|
5
5
|
<p style="margin:0 0 16px;color:${l};font-size:0.8rem;">Waiting for verification...</p>
|
|
6
6
|
<button id="signet-cancel" style="background:none;border:1px solid ${l};color:${y};padding:10px 24px;border-radius:8px;cursor:pointer;font-size:0.9rem;">Cancel</button>
|
|
7
|
-
`,document.body.appendChild(x),x.showModal();let A=x.querySelector("#signet-qr");if(A){let L=document.createElement("div");L.style.cssText=`width:200px;height:200px;background:${u?"#2a2a3e":"#f0f0f0"};border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:${l};word-break:break-all;padding:12px;`,L.textContent=`signet:verify:${c.slice(0,40)}...`,A.appendChild(L)}let B=new BroadcastChannel("signet-verify-"+o);x.querySelector("#signet-cancel")?.addEventListener("click",()=>{B.close(),x.close(),x.remove(),a.remove(),f({verified:!1,ageRange:null,tier:null,entityType:null,credentialId:null,verifierPubkey:null,verifierConfirmed:null,verifierMethod:null,issuedAt:null,expiresAt:null,error:"cancelled"})}),B.onmessage=async L=>{let D=L.data;if(typeof D!="object"||D===null)return;let P=D;if(P.type!=="signet-verify-response"||P.requestId!==o||!P.credential||typeof P.credential!="object"||!Array.isArray(P.credential.tags))return;let j=P.credential,G=await Ki(j),K=Gt(j.tags,"age-range"),z=Gt(j.tags,"tier"),Y=Gt(j.tags,"entity-type"),$=Gt(j.tags,"expires"),F=K?zi(K,n.requiredAgeRange):!1,M=await Yi(j.pubkey,n.verifierCheckUrl),C=M?.confirmed??null,H=M?.method??null,h=n.acceptUnconfirmed||C===!0;x.close(),x.remove(),a.remove(),B.close();let b=z?parseInt(z,10):null,v=$?parseInt($,10):null,S=Math.floor(Date.now()/1e3),O=v===null||!isNaN(v)&&v>S,p;G?O?F?h||(p=C===!1?"verifier-not-confirmed":"verifier-check-unavailable"):p="age-range-not-met":p="credential-expired":p="invalid-credential",f({verified:G&&O&&F&&h,ageRange:K||null,tier:b!==null&&!isNaN(b)?b:null,entityType:Y||null,credentialId:j.id,verifierPubkey:j.pubkey,verifierConfirmed:C,verifierMethod:H,issuedAt:j.created_at,expiresAt:v!==null&&!isNaN(v)?v:null,error:p})},setTimeout(()=>{x.close(),x.remove(),a.remove(),B.close(),f({verified:!1,ageRange:null,tier:null,entityType:null,credentialId:null,verifierPubkey:null,verifierConfirmed:null,verifierMethod:null,issuedAt:null,expiresAt:null,error:"timeout"})},n.timeout)})}var ot=300,Qo="wss://
|
|
7
|
+
`,document.body.appendChild(x),x.showModal();let A=x.querySelector("#signet-qr");if(A){let L=document.createElement("div");L.style.cssText=`width:200px;height:200px;background:${u?"#2a2a3e":"#f0f0f0"};border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:${l};word-break:break-all;padding:12px;`,L.textContent=`signet:verify:${c.slice(0,40)}...`,A.appendChild(L)}let B=new BroadcastChannel("signet-verify-"+o);x.querySelector("#signet-cancel")?.addEventListener("click",()=>{B.close(),x.close(),x.remove(),a.remove(),f({verified:!1,ageRange:null,tier:null,entityType:null,credentialId:null,verifierPubkey:null,verifierConfirmed:null,verifierMethod:null,issuedAt:null,expiresAt:null,error:"cancelled"})}),B.onmessage=async L=>{let D=L.data;if(typeof D!="object"||D===null)return;let P=D;if(P.type!=="signet-verify-response"||P.requestId!==o||!P.credential||typeof P.credential!="object"||!Array.isArray(P.credential.tags))return;let j=P.credential,G=await Ki(j),K=Gt(j.tags,"age-range"),z=Gt(j.tags,"tier"),Y=Gt(j.tags,"entity-type"),$=Gt(j.tags,"expires"),F=K?zi(K,n.requiredAgeRange):!1,M=await Yi(j.pubkey,n.verifierCheckUrl),C=M?.confirmed??null,H=M?.method??null,h=n.acceptUnconfirmed||C===!0;x.close(),x.remove(),a.remove(),B.close();let b=z?parseInt(z,10):null,v=$?parseInt($,10):null,S=Math.floor(Date.now()/1e3),O=v===null||!isNaN(v)&&v>S,p;G?O?F?h||(p=C===!1?"verifier-not-confirmed":"verifier-check-unavailable"):p="age-range-not-met":p="credential-expired":p="invalid-credential",f({verified:G&&O&&F&&h,ageRange:K||null,tier:b!==null&&!isNaN(b)?b:null,entityType:Y||null,credentialId:j.id,verifierPubkey:j.pubkey,verifierConfirmed:C,verifierMethod:H,issuedAt:j.created_at,expiresAt:v!==null&&!isNaN(v)?v:null,error:p})},setTimeout(()=>{x.close(),x.remove(),a.remove(),B.close(),f({verified:!1,ageRange:null,tier:null,entityType:null,credentialId:null,verifierPubkey:null,verifierConfirmed:null,verifierMethod:null,issuedAt:null,expiresAt:null,error:"timeout"})},n.timeout)})}var ot=300,Qo="wss://nos.lol",Xo=60;function Qn(t){let e=JSON.stringify([0,t.pubkey,t.created_at,t.kind,t.tags,t.content]),r=$e(new TextEncoder().encode(e));return Re(r)}async function Wi(t,e){try{let r=Xn(e,t.pubkey),n=Jn(t.content,r),o=JSON.parse(n);if(typeof o!="object"||o===null)return null;let s=o;if(s.kind!==13||typeof s.pubkey!="string"||!/^[0-9a-f]{64}$/i.test(s.pubkey)||typeof s.created_at!="number"||!Array.isArray(s.tags)||typeof s.content!="string"||typeof s.id!="string"||!/^[0-9a-f]{64}$/i.test(s.id)||typeof s.sig!="string"||!/^[0-9a-f]{128}$/i.test(s.sig)||Qn({pubkey:s.pubkey,created_at:s.created_at,kind:13,tags:s.tags,content:s.content})!==s.id.toLowerCase())return null;let c=Ae(s.sig),f=Ae(s.id),a=Ae(s.pubkey);if(!at.verify(c,f,a))return null;let u=Xn(e,s.pubkey),d=Jn(s.content,u),y=JSON.parse(d);if(typeof y!="object"||y===null)return null;let l=y;if(typeof l.pubkey!="string"||l.pubkey!==s.pubkey||typeof l.kind!="number"||typeof l.created_at!="number"||!Array.isArray(l.tags)||typeof l.content!="string")return null;let x=typeof l.id=="string"?l.id:Qn({pubkey:l.pubkey,created_at:l.created_at,kind:l.kind,tags:l.tags,content:l.content});return{pubkey:l.pubkey,id:x,kind:l.kind,created_at:l.created_at,tags:l.tags,content:l.content}}catch{return null}}function Gi(t){return es(t,64)}function es(t,e){if(typeof t!="string")return;let r=t.replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069]/g,"").trim().slice(0,e);return r.length>0?r:void 0}async function ts(t){let e=a=>{let u=new Error(a);return u.code=a,u};if(!/^[0-9a-f]{64}$/i.test(t.requestId))throw e("invalid-request-id");if(!(t.sessionPrivKey instanceof Uint8Array)||t.sessionPrivKey.length!==32)throw e("invalid-session-privkey");if(!/^wss:\/\//i.test(t.relayUrl)&&!/^ws:\/\/(localhost|127\.0\.0\.1)([:\/]|$)/i.test(t.relayUrl))throw e("invalid-relay-url");if(typeof t.expectedOrigin!="string"||t.expectedOrigin.length===0)throw e("invalid-expected-origin");if(Number.isFinite(t.issuedAt)&&t.issuedAt>Math.floor(Date.now()/1e3)+ot)throw e("invalid-issued-at");let r=Re(at.getPublicKey(t.sessionPrivKey)),n=Number.isFinite(t.issuedAt)?(Math.floor(t.issuedAt)+ot)*1e3-Date.now():12e4,o=Math.max(5e3,Math.min(t.timeout??n,6e5)),s=t.requestId.toLowerCase(),i=t.expectedOrigin,c=Number.isFinite(t.since)?Math.floor(t.since):Number.isFinite(t.issuedAt)?Math.floor(t.issuedAt)-Xo:Math.floor(Date.now()/1e3)-Xo,f=()=>Number.isFinite(t.issuedAt)&&Date.now()>=(Math.floor(t.issuedAt)+ot)*1e3-1e3;return new Promise((a,u)=>{let d=`sa-${Math.random().toString(36).slice(2,12)}`,y=!1,l=!1,x,A,B=Date.now()+o,L=F=>{if(!y){y=!0,clearTimeout(P),clearTimeout(A),typeof document<"u"&&document.removeEventListener("visibilitychange",G),t.abortSignal?.removeEventListener("abort",$);try{x?.close()}catch{}F()}},D=()=>L(()=>u(e(l||f()?"expired":"timeout"))),P=setTimeout(()=>{D()},o),j=()=>{!y&&x?.readyState===1&&x.send(JSON.stringify(["REQ",d,{kinds:[1059],"#p":[r],since:c}]))},G=()=>{if(!(y||document.visibilityState!=="visible")){if(Date.now()>=B)return D();!x||x.readyState>=2?Y():j()}};typeof document<"u"&&document.addEventListener("visibilitychange",G);let K=async F=>{if(y)return;let M;try{M=JSON.parse(typeof F.data=="string"?F.data:"")}catch{return}if(!Array.isArray(M))return;if(M[0]==="CLOSED"&&M[1]===d){let U=es(M[2],200);L(()=>{let V=e("relay-refused");U&&(V.reason=U),u(V)});return}if(M[0]!=="EVENT"||M[1]!==d)return;let C=M[2];if(typeof C!="object"||C===null)return;let H=C;if(H.kind!==1059||typeof H.pubkey!="string"||typeof H.content!="string")return;let h=await Wi({pubkey:H.pubkey,content:H.content},t.sessionPrivKey);if(!h||h.kind!==29999)return;let b=h.tags.find(U=>U[0]==="session");if(!b||b[1]!==s)return;let v=h.tags.find(U=>U[0]==="status");if(v?.[1]==="rejected"){L(()=>u(e("denied")));return}if(v?.[1]!=="approved")return;if(Math.abs(Date.now()/1e3-h.created_at)>ot){l=!0;return}let O;try{let U=JSON.parse(h.content);if(typeof U!="object"||U===null)return;O=U}catch{return}if(O.type!=="signet-auth-response"||O.requestId!==s||typeof O.authEvent!="object"||O.authEvent===null)return;let p=O.authEvent;if(typeof p.id!="string"||!/^[0-9a-f]{64}$/i.test(p.id)||typeof p.pubkey!="string"||!/^[0-9a-f]{64}$/i.test(p.pubkey)||typeof p.sig!="string"||!/^[0-9a-f]{128}$/i.test(p.sig)||p.kind!==21236||typeof p.created_at!="number"||!Array.isArray(p.tags)||typeof p.content!="string"||p.pubkey.toLowerCase()!==h.pubkey.toLowerCase()||Qn({pubkey:p.pubkey,created_at:p.created_at,kind:21236,tags:p.tags,content:p.content})!==p.id.toLowerCase())return;let w=!1;try{let U=Ae(p.sig),V=Ae(p.id),Z=Ae(p.pubkey);w=at.verify(U,V,Z)}catch{w=!1}if(!w)return;let g=p.tags,E=g.find(U=>Array.isArray(U)&&U[0]==="challenge");if(!E||typeof E[1]!="string"||E[1].toLowerCase()!==s)return;let I=g.find(U=>Array.isArray(U)&&U[0]==="origin");if(!I||I[1]!==i)return;if(Math.abs(Date.now()/1e3-p.created_at)>ot){l=!0;return}let T={id:p.id.toLowerCase(),pubkey:p.pubkey.toLowerCase(),kind:21236,created_at:p.created_at,tags:g,content:p.content,sig:p.sig},k=Gi(O.displayName),q=typeof O.bunkerUri=="string"&&/^bunker:\/\//i.test(O.bunkerUri)?O.bunkerUri:void 0;L(()=>a({pubkey:T.pubkey,authEvent:T,credential:O.credential,...k!==void 0?{displayName:k}:{},...q!==void 0?{bunkerUri:q}:{},createdAt:T.created_at}))},z=F=>{if(!y){if(typeof document>"u")return L(()=>u(e(F)));clearTimeout(A),document.visibilityState==="visible"&&(A=setTimeout(Y,1e3))}};function Y(){if(y)return;if(clearTimeout(A),Date.now()>=B)return D();let F=x;x=void 0;try{F?.close()}catch{}try{let M=new WebSocket(t.relayUrl);x=M,M.onopen=j,M.onmessage=K,M.onerror=()=>{x===M&&z("relay-error")},M.onclose=()=>{x===M&&z("relay-closed")}}catch{z("relay-error")}}let $=()=>L(()=>u(e("aborted")));if(t.abortSignal?.aborted){L(()=>u(e("aborted")));return}t.abortSignal?.addEventListener("abort",$,{once:!0}),Y()})}typeof window<"u"&&(window.Signet={verifyAge:Jo,waitForAuthResponse:ts});return fs(Xi);})();
|
|
8
8
|
/*! Bundled license information:
|
|
9
9
|
|
|
10
10
|
@noble/curves/utils.js:
|
package/dist/signet-verify.js
CHANGED
|
@@ -299,7 +299,7 @@ export const AUTH_FRESHNESS_WINDOW_SEC = 300;
|
|
|
299
299
|
* relay.damus.io, the previous default, refuses those reads and has AUTH
|
|
300
300
|
* misconfigured, so nothing can fetch a response from it.
|
|
301
301
|
*/
|
|
302
|
-
export const DEFAULT_RELAY_URL = 'wss://
|
|
302
|
+
export const DEFAULT_RELAY_URL = 'wss://nos.lol';
|
|
303
303
|
/**
|
|
304
304
|
* Slack subtracted from `issuedAt` when deriving the relay query window, to
|
|
305
305
|
* absorb clock skew between the consumer and the signer device. Widening the
|
package/llms.txt
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Signet Verify
|
|
2
|
+
|
|
3
|
+
> Age verification on a website usually means collecting ID or sending users
|
|
4
|
+
> off to a third-party checker. signet-verify is a browser SDK that checks a
|
|
5
|
+
> cryptographic age credential from the Signet app instead, without the site
|
|
6
|
+
> ever seeing personal data.
|
|
7
|
+
|
|
8
|
+
For websites that need to gate content by age range (adult content, age
|
|
9
|
+
brackets for minors) without running their own verification flow. A single
|
|
10
|
+
function call opens a modal, waits for a signed response on a same-origin
|
|
11
|
+
`BroadcastChannel` (no relay connection), and checks both the credential and
|
|
12
|
+
the issuing verifier's professional registration.
|
|
13
|
+
|
|
14
|
+
## Getting Started
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install signet-verify
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
import { verifyAge } from 'signet-verify';
|
|
22
|
+
|
|
23
|
+
const result = await verifyAge('18+');
|
|
24
|
+
if (result.verified) {
|
|
25
|
+
// user confirmed 18+ by a confirmed professional verifier
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For a script tag / CDN build, use the IIFE export (`signet-verify/iife`),
|
|
30
|
+
which exposes `window.Signet`, for example
|
|
31
|
+
`https://cdn.jsdelivr.net/npm/signet-verify@0.7.1/dist/signet-verify.iife.js`.
|
|
32
|
+
|
|
33
|
+
## Key Concepts
|
|
34
|
+
|
|
35
|
+
- **Credential, not identity**: the proof carries an age range and verifier
|
|
36
|
+
info, never a name, date of birth or other PII.
|
|
37
|
+
- **Verifier confirmation**: by default a result only counts as `verified`
|
|
38
|
+
when the issuing verifier is checked against a public professional
|
|
39
|
+
register (GMC, SRA, TRA, etc.) via a verification bot; this can be relaxed
|
|
40
|
+
with `acceptUnconfirmed` or pointed at a different bot with
|
|
41
|
+
`verifierCheckUrl`.
|
|
42
|
+
- **Same-browser vs cross-device**: `verifyAge` covers the modal flow and
|
|
43
|
+
receives its response on a `BroadcastChannel` named
|
|
44
|
+
`signet-verify-<requestId>`; it makes one network request, to the
|
|
45
|
+
verification bot, and never connects to a relay (its `relayUrl` option is
|
|
46
|
+
only written into the request object). `waitForAuthResponse` is the
|
|
47
|
+
lower-level cross-device primitive for a consumer that mints its own
|
|
48
|
+
challenge and waits on a relay for the signed response; only it needs a
|
|
49
|
+
relay that serves kind-1059 gift wraps to an unauthenticated reader.
|
|
50
|
+
- **The sign-in deadline**: a cross-device wait has one deadline,
|
|
51
|
+
`issuedAt + AUTH_FRESHNESS_WINDOW_SEC`. `issuedAt` must be passed on every
|
|
52
|
+
call for the same sign-in, including retries after the page was
|
|
53
|
+
backgrounded, or the relay query anchor is wrong.
|
|
54
|
+
|
|
55
|
+
## API Surface
|
|
56
|
+
|
|
57
|
+
- `verifyAge(requiredAgeRange, options?) => Promise<SignetVerifyResult>`:
|
|
58
|
+
the same-browser modal flow. `requiredAgeRange` must be one of
|
|
59
|
+
`'0-3'`, `'4-7'`, `'8-12'`, `'13-17'`, `'18+'` and is matched exactly.
|
|
60
|
+
Failures are reported in `result.error`, not as a rejection.
|
|
61
|
+
- `waitForAuthResponse(options) => Promise<SignetAuthResult>`: the
|
|
62
|
+
cross-device primitive. It resolves on success and rejects with an `Error`
|
|
63
|
+
whose `message` and `code` are the failure code (`expired`, `timeout`,
|
|
64
|
+
`denied`, `aborted`, `relay-refused`, and others listed in the README).
|
|
65
|
+
Options: `requestId`, `relayUrl`, `sessionPrivKey` (a 32-byte
|
|
66
|
+
`Uint8Array`) and `expectedOrigin` are required; `issuedAt` (unix
|
|
67
|
+
seconds), `timeout` (milliseconds), `since` (unix seconds) and
|
|
68
|
+
`abortSignal` are optional. The README has the full option and result
|
|
69
|
+
tables.
|
|
70
|
+
- `AUTH_FRESHNESS_WINDOW_SEC` (`300`, seconds), `DEFAULT_RELAY_URL`
|
|
71
|
+
(`'wss://nos.lol'`): constants used to compute and check the sign-in
|
|
72
|
+
deadline.
|
|
73
|
+
- Result and option types: `SignetVerifyResult`, `SignetVerifyOptions`,
|
|
74
|
+
`WaitForAuthOptions`, `SignetAuthResult`, `SignetAuthEvent`.
|
|
75
|
+
|
|
76
|
+
## Optional
|
|
77
|
+
|
|
78
|
+
- Full API reference, error codes and age range values: [README](README.md)
|
|
79
|
+
- Cross-device example: [examples/cross-device-login.html](https://github.com/forgesworn/signet-verify/blob/main/examples/cross-device-login.html)
|
|
80
|
+
- Release history: [CHANGELOG.md](CHANGELOG.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signet-verify",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Signet SDK — website age verification and cross-device Sign-in-with-Signet",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/signet-verify.js",
|
|
@@ -14,7 +14,26 @@
|
|
|
14
14
|
"./iife": "./dist/signet-verify.iife.js"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
-
"dist"
|
|
17
|
+
"dist",
|
|
18
|
+
"llms.txt",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://github.com/forgesworn/signet-verify#readme",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"signet",
|
|
24
|
+
"age-verification",
|
|
25
|
+
"age-gate",
|
|
26
|
+
"nostr",
|
|
27
|
+
"credential",
|
|
28
|
+
"verifiable-credential",
|
|
29
|
+
"cross-device",
|
|
30
|
+
"sign-in",
|
|
31
|
+
"authentication",
|
|
32
|
+
"privacy",
|
|
33
|
+
"gdpr",
|
|
34
|
+
"compliance",
|
|
35
|
+
"sdk",
|
|
36
|
+
"browser"
|
|
18
37
|
],
|
|
19
38
|
"publishConfig": {
|
|
20
39
|
"provenance": true
|