signet-protocol 1.0.0 → 1.2.0

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.
Files changed (71) hide show
  1. package/README.md +19 -2
  2. package/dist/badge.d.ts +3 -1
  3. package/dist/badge.d.ts.map +1 -1
  4. package/dist/badge.js +8 -0
  5. package/dist/badge.js.map +1 -1
  6. package/dist/cold-call.d.ts +2 -2
  7. package/dist/cold-call.d.ts.map +1 -1
  8. package/dist/cold-call.js +15 -12
  9. package/dist/cold-call.js.map +1 -1
  10. package/dist/constants.d.ts +16 -2
  11. package/dist/constants.d.ts.map +1 -1
  12. package/dist/constants.js +17 -3
  13. package/dist/constants.js.map +1 -1
  14. package/dist/credentials.d.ts +4 -0
  15. package/dist/credentials.d.ts.map +1 -1
  16. package/dist/credentials.js +41 -4
  17. package/dist/credentials.js.map +1 -1
  18. package/dist/index.d.ts +10 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +17 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/migration.d.ts +39 -0
  23. package/dist/migration.d.ts.map +1 -0
  24. package/dist/migration.js +95 -0
  25. package/dist/migration.js.map +1 -0
  26. package/dist/presentation.d.ts +50 -0
  27. package/dist/presentation.d.ts.map +1 -0
  28. package/dist/presentation.js +126 -0
  29. package/dist/presentation.js.map +1 -0
  30. package/dist/qr-router.d.ts +64 -0
  31. package/dist/qr-router.d.ts.map +1 -0
  32. package/dist/qr-router.js +274 -0
  33. package/dist/qr-router.js.map +1 -0
  34. package/dist/relay-events.d.ts +43 -0
  35. package/dist/relay-events.d.ts.map +1 -0
  36. package/dist/relay-events.js +64 -0
  37. package/dist/relay-events.js.map +1 -0
  38. package/dist/signet-me.d.ts +30 -0
  39. package/dist/signet-me.d.ts.map +1 -0
  40. package/dist/signet-me.js +55 -0
  41. package/dist/signet-me.js.map +1 -0
  42. package/dist/signing-backend.d.ts +25 -0
  43. package/dist/signing-backend.d.ts.map +1 -0
  44. package/dist/signing-backend.js +10 -0
  45. package/dist/signing-backend.js.map +1 -0
  46. package/dist/trust-score.d.ts.map +1 -1
  47. package/dist/trust-score.js +30 -24
  48. package/dist/trust-score.js.map +1 -1
  49. package/dist/url-auth.d.ts +29 -0
  50. package/dist/url-auth.d.ts.map +1 -0
  51. package/dist/url-auth.js +109 -0
  52. package/dist/url-auth.js.map +1 -0
  53. package/dist/venue-entry.d.ts +23 -0
  54. package/dist/venue-entry.d.ts.map +1 -0
  55. package/dist/venue-entry.js +39 -0
  56. package/dist/venue-entry.js.map +1 -0
  57. package/package.json +1 -1
  58. package/src/badge.ts +11 -1
  59. package/src/cold-call.ts +16 -12
  60. package/src/constants.ts +18 -3
  61. package/src/credentials.ts +44 -4
  62. package/src/index.ts +63 -0
  63. package/src/migration.ts +110 -0
  64. package/src/presentation.ts +165 -0
  65. package/src/qr-router.ts +280 -0
  66. package/src/relay-events.ts +98 -0
  67. package/src/signet-me.ts +104 -0
  68. package/src/signing-backend.ts +29 -0
  69. package/src/trust-score.ts +30 -24
  70. package/src/url-auth.ts +115 -0
  71. package/src/venue-entry.ts +49 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Signet Me — Directional verification using spoken-token.
3
+ *
4
+ * Each person gets a DIFFERENT word. Prevents the echo attack.
5
+ * Uses spoken-token's deriveDirectionalPair for domain-separated,
6
+ * time-windowed verification words.
7
+ */
8
+
9
+ import { deriveDirectionalPair } from 'spoken-token';
10
+ import type { TokenEncoding } from 'spoken-token/encoding';
11
+ import { WORDLIST } from 'spoken-token/wordlist';
12
+
13
+ const SIGNET_ME_NAMESPACE = 'signet:me';
14
+
15
+ /** Default rotation period in seconds. */
16
+ export const SIGNET_ME_ROTATION_SECONDS = 30;
17
+
18
+ /** Default tolerance in epochs (±1 for clock skew). */
19
+ export const SIGNET_ME_TOLERANCE = 1;
20
+
21
+ export interface SignetMeDisplay {
22
+ /** Words I say to prove it's me */
23
+ myWords: string[];
24
+ /** Words I expect to hear from them */
25
+ theirWords: string[];
26
+ /** Seconds until words refresh */
27
+ expiresIn: number;
28
+ }
29
+
30
+ function getCounter(nowMs: number, rotationSeconds: number): number {
31
+ return Math.floor(nowMs / 1000 / rotationSeconds);
32
+ }
33
+
34
+ function makeEncoding(wordCount: number): TokenEncoding {
35
+ return { format: 'words', count: wordCount, wordlist: WORDLIST };
36
+ }
37
+
38
+ /**
39
+ * Get directional words for a Signet Me verification.
40
+ * Each side sees different words — I say mine, they say theirs.
41
+ */
42
+ export function getSignetMeDisplay(
43
+ sharedSecret: string,
44
+ myPubkey: string,
45
+ theirPubkey: string,
46
+ wordCount: number = 1,
47
+ nowMs?: number,
48
+ ): SignetMeDisplay {
49
+ const now = nowMs ?? Date.now();
50
+ const counter = getCounter(now, SIGNET_ME_ROTATION_SECONDS);
51
+ const encoding = makeEncoding(wordCount);
52
+
53
+ const pair = deriveDirectionalPair(
54
+ sharedSecret,
55
+ SIGNET_ME_NAMESPACE,
56
+ [myPubkey, theirPubkey],
57
+ counter,
58
+ encoding,
59
+ );
60
+
61
+ const myWords = pair[myPubkey].split(' ');
62
+ const theirWords = pair[theirPubkey].split(' ');
63
+
64
+ const epochMs = SIGNET_ME_ROTATION_SECONDS * 1000;
65
+ const msIntoEpoch = now % epochMs;
66
+ const expiresIn = Math.ceil((epochMs - msIntoEpoch) / 1000);
67
+
68
+ return { myWords, theirWords, expiresIn };
69
+ }
70
+
71
+ /**
72
+ * Verify that the spoken words match what the other person should say.
73
+ * Checks current counter ±tolerance for clock skew.
74
+ */
75
+ export function verifySignetMe(
76
+ sharedSecret: string,
77
+ myPubkey: string,
78
+ theirPubkey: string,
79
+ spokenWords: string[],
80
+ wordCount: number = 1,
81
+ nowMs?: number,
82
+ ): boolean {
83
+ const now = nowMs ?? Date.now();
84
+ const currentCounter = getCounter(now, SIGNET_ME_ROTATION_SECONDS);
85
+ const encoding = makeEncoding(wordCount);
86
+ const spokenJoined = spokenWords.map(w => w.toLowerCase().trim()).join(' ');
87
+
88
+ for (let offset = -SIGNET_ME_TOLERANCE; offset <= SIGNET_ME_TOLERANCE; offset++) {
89
+ const pair = deriveDirectionalPair(
90
+ sharedSecret,
91
+ SIGNET_ME_NAMESPACE,
92
+ [myPubkey, theirPubkey],
93
+ currentCounter + offset,
94
+ encoding,
95
+ );
96
+
97
+ // I'm verifying THEIR word — what they should say to me
98
+ if (pair[theirPubkey] === spokenJoined) {
99
+ return true;
100
+ }
101
+ }
102
+
103
+ return false;
104
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Signing Backend Interface
3
+ *
4
+ * Defines the abstract SigningBackend interface that all signing
5
+ * implementations must conform to. Concrete implementations live
6
+ * in the consuming application (LocalSigningBackend, BunkerSigningBackend,
7
+ * Nip07SigningBackend) because they depend on app-level NIP-44 encryption.
8
+ */
9
+
10
+ import type { NostrEvent, UnsignedEvent } from './types.js';
11
+
12
+ /** Signing mode for backend type discrimination. */
13
+ export type SigningMode = 'local' | 'bunker' | 'nip07';
14
+
15
+ /**
16
+ * Abstract signing backend interface.
17
+ *
18
+ * All signing operations go through this interface. The protocol library
19
+ * defines the contract; the application provides concrete implementations.
20
+ */
21
+ export interface SigningBackend {
22
+ readonly type: SigningMode;
23
+ activePublicKeyHex: string;
24
+
25
+ signEvent(event: UnsignedEvent): Promise<NostrEvent>;
26
+ nip44Encrypt(recipientPubkey: string, plaintext: string): Promise<string>;
27
+
28
+ destroy(): void;
29
+ }
@@ -1,7 +1,7 @@
1
1
  // Signet Score Computation
2
2
  // Continuous 0-200 Signet Score from weighted signals
3
3
 
4
- import { TRUST_WEIGHTS, MAX_TRUST_SCORE, ATTESTATION_KIND, ATTESTATION_TYPES } from './constants.js';
4
+ import { TRUST_WEIGHTS, TRUST_CAPS, MAX_TRUST_SCORE, ATTESTATION_KIND, ATTESTATION_TYPES } from './constants.js';
5
5
  import { getTagValue } from './validation.js';
6
6
  import type {
7
7
  NostrEvent,
@@ -54,13 +54,15 @@ export function computeTrustScore(
54
54
  const verificationType = getTagValue(cred, 'verification-type');
55
55
  if (verificationType === 'professional') {
56
56
  professionalVerifications++;
57
- const weight = TRUST_WEIGHTS.PROFESSIONAL_VERIFICATION;
58
- rawScore += weight;
59
- signals.push({
60
- type: 'professional-verification',
61
- weight,
62
- source: cred.pubkey,
63
- });
57
+ if (professionalVerifications <= TRUST_CAPS.PROFESSIONAL_VERIFICATION) {
58
+ const weight = TRUST_WEIGHTS.PROFESSIONAL_VERIFICATION;
59
+ rawScore += weight;
60
+ signals.push({
61
+ type: 'professional-verification',
62
+ weight,
63
+ source: cred.pubkey,
64
+ });
65
+ }
64
66
  }
65
67
  }
66
68
 
@@ -84,24 +86,28 @@ export function computeTrustScore(
84
86
 
85
87
  if (method === 'in-person') {
86
88
  inPersonVouches++;
87
- const weight = TRUST_WEIGHTS.IN_PERSON_VOUCH * scoreMultiplier;
88
- rawScore += weight;
89
- signals.push({
90
- type: 'in-person-vouch',
91
- weight,
92
- source: vouch.pubkey,
93
- score: voucherScore,
94
- });
89
+ if (inPersonVouches <= TRUST_CAPS.IN_PERSON_VOUCH) {
90
+ const weight = TRUST_WEIGHTS.IN_PERSON_VOUCH * scoreMultiplier;
91
+ rawScore += weight;
92
+ signals.push({
93
+ type: 'in-person-vouch',
94
+ weight,
95
+ source: vouch.pubkey,
96
+ score: voucherScore,
97
+ });
98
+ }
95
99
  } else {
96
100
  onlineVouches++;
97
- const weight = TRUST_WEIGHTS.ONLINE_VOUCH * scoreMultiplier;
98
- rawScore += weight;
99
- signals.push({
100
- type: 'online-vouch',
101
- weight,
102
- source: vouch.pubkey,
103
- score: voucherScore,
104
- });
101
+ if (onlineVouches <= TRUST_CAPS.ONLINE_VOUCH) {
102
+ const weight = TRUST_WEIGHTS.ONLINE_VOUCH * scoreMultiplier;
103
+ rawScore += weight;
104
+ signals.push({
105
+ type: 'online-vouch',
106
+ weight,
107
+ source: vouch.pubkey,
108
+ score: voucherScore,
109
+ });
110
+ }
105
111
  }
106
112
  }
107
113
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * URL-based authentication for "Sign in with Signet" redirect flow.
3
+ *
4
+ * External websites redirect to the Signet app with URL params containing
5
+ * a challenge, origin, callback, and site name. The app signs the challenge
6
+ * and redirects back with the signature.
7
+ */
8
+
9
+ import type { LoginRequest } from './qr-router.js';
10
+
11
+ /** Check if a URL is https:// or http://localhost (dev). */
12
+ function isValidAuthUrl(url: string): boolean {
13
+ try {
14
+ const parsed = new URL(url);
15
+ if (parsed.protocol === 'https:') return true;
16
+ if (parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')) return true;
17
+ return false;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Parse URL auth params from a query string (e.g. window.location.search).
25
+ * Returns a LoginRequest compatible with the approval flow,
26
+ * or null if params are missing/invalid.
27
+ */
28
+ export function parseUrlAuthParams(search: string): LoginRequest | null {
29
+ const params = new URLSearchParams(search);
30
+ if (params.get('auth') !== '1') return null;
31
+
32
+ const challenge = params.get('challenge');
33
+ const origin = params.get('origin');
34
+ const name = params.get('name');
35
+ const callback = params.get('callback');
36
+
37
+ // All four params required
38
+ if (!challenge || !origin || !name || !callback) return null;
39
+
40
+ // Challenge must be 64 hex chars (normalize to lowercase)
41
+ if (!/^[0-9a-f]{64}$/i.test(challenge)) return null;
42
+ const normalizedChallenge = challenge.toLowerCase();
43
+
44
+ // Name must be non-empty, max 64 chars
45
+ if (name.length === 0 || name.length > 64) return null;
46
+
47
+ // Origin must be https:// (or http://localhost for dev)
48
+ if (!isValidAuthUrl(origin)) return null;
49
+
50
+ // Callback must be https:// (or http://localhost for dev)
51
+ if (!isValidAuthUrl(callback)) return null;
52
+
53
+ // Callback origin must match origin param (prevent open redirect)
54
+ try {
55
+ const callbackOrigin = new URL(callback).origin;
56
+ const requestOrigin = new URL(origin).origin;
57
+ if (callbackOrigin !== requestOrigin) return null;
58
+ } catch {
59
+ return null;
60
+ }
61
+
62
+ const timestampParam = params.get('t') || params.get('timestamp');
63
+ if (!timestampParam) return null; // timestamp required for replay protection
64
+ const timestamp = parseInt(timestampParam, 10);
65
+ if (isNaN(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return null;
66
+
67
+ return {
68
+ type: 'signet-login-request',
69
+ requestId: normalizedChallenge, // use challenge as requestId for URL auth
70
+ challenge: normalizedChallenge,
71
+ origin,
72
+ callbackUrl: callback,
73
+ timestamp,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Build the callback redirect URL after successful auth.
79
+ * Uses the URL API to safely append params (no string concatenation).
80
+ */
81
+ export function buildAuthCallbackUrl(
82
+ callbackUrl: string,
83
+ pubkey: string,
84
+ npub: string,
85
+ signature: string,
86
+ eventId: string,
87
+ ): string {
88
+ if (!isValidAuthUrl(callbackUrl)) throw new Error('Invalid callback URL scheme');
89
+ const url = new URL(callbackUrl);
90
+ url.searchParams.set('pubkey', pubkey);
91
+ url.searchParams.set('npub', npub);
92
+ url.searchParams.set('signature', signature);
93
+ url.searchParams.set('eventId', eventId);
94
+ return url.toString();
95
+ }
96
+
97
+ /**
98
+ * Build the callback redirect URL for a denied auth request.
99
+ */
100
+ export function buildAuthDeniedUrl(callbackUrl: string): string {
101
+ if (!isValidAuthUrl(callbackUrl)) throw new Error('Invalid callback URL scheme');
102
+ const url = new URL(callbackUrl);
103
+ url.searchParams.set('error', 'denied');
104
+ return url.toString();
105
+ }
106
+
107
+ /**
108
+ * Extract and sanitise the site name from URL auth params.
109
+ * Strips control characters and bidirectional text markers, caps at 64 chars.
110
+ */
111
+ export function getUrlAuthSiteName(search: string): string {
112
+ const params = new URLSearchParams(search);
113
+ const name = params.get('name') ?? '';
114
+ return name.replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069]/g, '').slice(0, 64);
115
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Venue Entry Event Builder (kind 21235)
3
+ *
4
+ * Builds unsigned venue entry events for physical venue scanning.
5
+ * The event is a self-contained, signed QR payload verifiable via
6
+ * standard NIP-01 signature verification.
7
+ *
8
+ * Kind 21235 is in the ephemeral range. The consuming application
9
+ * signs the event and renders it as a QR code.
10
+ */
11
+
12
+ import type { UnsignedEvent } from './types.js';
13
+
14
+ /** Venue entry event kind (ephemeral range). */
15
+ export const VENUE_ENTRY_KIND = 21235;
16
+
17
+ /**
18
+ * Build an unsigned venue entry event template.
19
+ * The caller must sign this before rendering as a QR code.
20
+ *
21
+ * @param pubkey - The natural person's hex public key.
22
+ * @param photoHash - Optional SHA-256 hash of the uploaded photo.
23
+ * @param blossomUrl - Optional Blossom server URL where the photo is hosted.
24
+ */
25
+ export function buildVenueEntryEventTemplate(
26
+ pubkey: string,
27
+ photoHash?: string,
28
+ blossomUrl?: string,
29
+ ): UnsignedEvent {
30
+ const tags: string[][] = [['t', 'signet-venue-entry']];
31
+
32
+ if (photoHash) {
33
+ tags.push(['x', photoHash]);
34
+ }
35
+
36
+ if (blossomUrl && photoHash) {
37
+ if (/^https:\/\//i.test(blossomUrl) || /^http:\/\/(localhost|127\.0\.0\.1)([:\/]|$)/i.test(blossomUrl)) {
38
+ tags.push(['blossom', blossomUrl]);
39
+ }
40
+ }
41
+
42
+ return {
43
+ pubkey,
44
+ kind: VENUE_ENTRY_KIND,
45
+ created_at: Math.floor(Date.now() / 1000),
46
+ tags,
47
+ content: '',
48
+ };
49
+ }