summaryrefslogtreecommitdiff
path: root/src/digiid.ts
blob: 5afabdebda84370fe0110ac5eba5ce6ed9095737 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import { randomBytes } from 'crypto';
import { 
  DigiIDUriOptions, 
  DigiIDError, 
  DigiIDCallbackData, 
  DigiIDVerifyOptions, 
  DigiIDVerificationResult 
} from './types';

// Use require for the CommonJS dependency installed from Git
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Message = require('digibyte-message');

/**
 * Generates a secure random nonce (hex string).
 * @param length - The number of bytes to generate (default: 16, resulting in 32 hex chars).
 * @returns A hex-encoded random string.
 */
function generateNonce(length = 16): string {
  return randomBytes(length).toString('hex');
}

/**
 * Generates a DigiID authentication URI.
 *
 * @param options - Options for URI generation, including the callback URL.
 * @returns The generated DigiID URI string.
 * @throws {DigiIDError} If the callback URL is invalid or missing.
 */
export function generateDigiIDUri(options: DigiIDUriOptions): string {
  if (!options.callbackUrl) {
    throw new DigiIDError('Callback URL is required.');
  }

  let parsedUrl: URL;
  try {
    parsedUrl = new URL(options.callbackUrl);
  } catch (e) {
    throw new DigiIDError(`Invalid callback URL: ${(e as Error).message}`);
  }

  // DigiID spec requires stripping the scheme (http/https)
  const domainAndPath = parsedUrl.host + parsedUrl.pathname;

  const nonce = options.nonce || generateNonce();
  const unsecureFlag = options.unsecure ? '1' : '0'; // 1 for http, 0 for https

  // Validate scheme based on unsecure flag
  if (options.unsecure && parsedUrl.protocol !== 'http:') {
    throw new DigiIDError('Unsecure flag is true, but callback URL does not use http protocol.');
  }
  if (!options.unsecure && parsedUrl.protocol !== 'https:') {
    throw new DigiIDError('Callback URL must use https protocol unless unsecure flag is set to true.');
  }

  // Construct the URI
  // Example: digiid://example.com/callback?x=nonce_value&u=0
  const uri = `digiid://${domainAndPath}?x=${nonce}&u=${unsecureFlag}`;

  // Clean up potential trailing slash in path if no query params exist (though DigiID always has params)
  // This check might be redundant given DigiID structure, but good practice
  // const cleanedUri = uri.endsWith('/') && parsedUrl.search === '' ? uri.slice(0, -1) : uri;

  return uri;
}

/**
 * Verifies the signature and data received from a DigiID callback.
 *
 * @param callbackData - The data received from the wallet (address, uri, signature).
 * @param verifyOptions - Options for verification, including the expected callback URL and nonce.
 * @returns {Promise<DigiIDVerificationResult>} A promise that resolves with verification details if successful.
 * @throws {DigiIDError} If validation or signature verification fails.
 */
export async function verifyDigiIDCallback(
  callbackData: DigiIDCallbackData,
  verifyOptions: DigiIDVerifyOptions
): Promise<DigiIDVerificationResult> {
  const { address, uri, signature } = callbackData;
  const { expectedCallbackUrl, expectedNonce } = verifyOptions;

  if (!address || !uri || !signature) {
    throw new DigiIDError('Missing required callback data: address, uri, or signature.');
  }

  // 1. Parse the received URI
  let parsedReceivedUri: URL;
  try {
    // Temporarily replace digiid:// with http:// for standard URL parsing
    const parsableUri = uri.replace(/^digiid:/, 'http:');
    parsedReceivedUri = new URL(parsableUri);
  } catch (e) {
    throw new DigiIDError(`Invalid URI received in callback: ${(e as Error).message}`);
  }

  const receivedNonce = parsedReceivedUri.searchParams.get('x');
  const receivedUnsecure = parsedReceivedUri.searchParams.get('u'); // 0 or 1
  const receivedDomainAndPath = parsedReceivedUri.host + parsedReceivedUri.pathname;

  if (receivedNonce === null || receivedUnsecure === null) {
    throw new DigiIDError('URI missing nonce (x) or unsecure (u) parameter.');
  }

  // 2. Validate Callback URL
  let parsedExpectedUrl: URL;
  try {
    // Allow expectedCallbackUrl to be a string or URL object
    parsedExpectedUrl = typeof expectedCallbackUrl === 'string' ? new URL(expectedCallbackUrl) : expectedCallbackUrl;
  } catch (e) {
    throw new DigiIDError(`Invalid expectedCallbackUrl provided: ${(e as Error).message}`);
  }

  const expectedDomainAndPath = parsedExpectedUrl.host + parsedExpectedUrl.pathname;

  if (receivedDomainAndPath !== expectedDomainAndPath) {
    throw new DigiIDError(`Callback URL mismatch: URI contained "${receivedDomainAndPath}", expected "${expectedDomainAndPath}"`);
  }

  // Validate scheme consistency
  const expectedScheme = parsedExpectedUrl.protocol;
  if (receivedUnsecure === '1' && expectedScheme !== 'http:') {
    throw new DigiIDError('URI indicates unsecure (u=1), but expectedCallbackUrl is not http.');
  }
  if (receivedUnsecure === '0' && expectedScheme !== 'https:') {
    throw new DigiIDError('URI indicates secure (u=0), but expectedCallbackUrl is not https.');
  }

  // 3. Validate Nonce (optional)
  if (expectedNonce && receivedNonce !== expectedNonce) {
    throw new DigiIDError(`Nonce mismatch: URI contained "${receivedNonce}", expected "${expectedNonce}". Possible replay attack.`);
  }

  // 4. Verify Signature using digibyte-message
  try {
    // The bitcore-message standard expects the message string, address, and signature.
    // The message signed is the full DigiID URI string.
    const messageInstance = new Message(uri);
    // The verify method might be synchronous or asynchronous depending on the underlying lib
    // Assuming synchronous based on common bitcore patterns, but wrapping for safety
    const isValidSignature = await Promise.resolve(messageInstance.verify(address, signature));

    if (!isValidSignature) {
      throw new DigiIDError('Invalid signature.');
    }
  } catch (e: any) {
    // Catch potential errors from the verify function (e.g., invalid address/signature format)
    throw new DigiIDError(`Signature verification failed: ${e.message || e}`);
  }

  // 5. Return successful result
  return {
    isValid: true,
    address: address,
    nonce: receivedNonce, // Return the nonce from the URI
  };
}