diff --git a/src/api/f.ts b/src/api/f.ts index 206cfb6..320c388 100644 --- a/src/api/f.ts +++ b/src/api/f.ts @@ -312,18 +312,26 @@ export interface ResourceData { } export class ZncaApiNxapi extends ZncaApi implements RequestEncryptionProvider { + readonly url: URL; readonly auth: NxapiZncaAuth | null; headers = new Headers(); constructor( - readonly url: string, + url: URL | string, auth: NxapiZncaAuth | null, readonly app?: {platform?: string; version?: string;}, useragent?: string, ) { super(useragent); + if (typeof url === 'string') { + url = new URL(url); + + if (!url.pathname.endsWith('/')) url.pathname += '/'; + } + + this.url = url; this.auth = auth; this.headers.set('User-Agent', getUserAgent(useragent)); @@ -345,8 +353,10 @@ export class ZncaApiNxapi extends ZncaApi implements RequestEncryptionProvider { ): Promise { if (this.auth && !this.auth.has_valid_token) await this.auth.authenticate(); + const url = new URL('f', this.url); + debugZncaApi('Getting f parameter', { - url: this.url + '/f', hash_method, token, timestamp: undefined, request_id: undefined, user, + url: url.href, hash_method, token, timestamp: undefined, request_id: undefined, user, znca_platform: this.app?.platform, znca_version: this.app?.version, encrypt_token_request, }); @@ -367,7 +377,7 @@ export class ZncaApiNxapi extends ZncaApi implements RequestEncryptionProvider { if (this.auth?.token) headers.append('Authorization', 'Bearer ' + this.auth.token.token); const [signal, cancel] = timeoutSignal(); - const response = await fetch(this.url + '/f', { + const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(req), @@ -399,7 +409,7 @@ export class ZncaApiNxapi extends ZncaApi implements RequestEncryptionProvider { return { provider: 'nxapi' as const, - url: this.url + '/f', + url: url.href, hash_method, token, timestamp: result.timestamp!, // will be included as not sent in request request_id: result.request_id!, @@ -430,7 +440,7 @@ export class ZncaApiNxapi extends ZncaApi implements RequestEncryptionProvider { if (this.auth?.token) headers.append('Authorization', 'Bearer ' + this.auth.token.token); const [signal, cancel] = timeoutSignal(); - const response = await fetch(this.url + '/encrypt-request', { + const response = await fetch(new URL('encrypt-request', this.url), { method: 'POST', headers, body: JSON.stringify(req), @@ -476,7 +486,7 @@ export class ZncaApiNxapi extends ZncaApi implements RequestEncryptionProvider { if (this.auth?.token) headers.append('Authorization', 'Bearer ' + this.auth.token.token); const [signal, cancel] = timeoutSignal(); - const response = await fetch(this.url + '/decrypt-response', { + const response = await fetch(new URL('decrypt-response', this.url), { method: 'POST', headers, body: JSON.stringify(req), @@ -511,6 +521,7 @@ export class NxapiZncaAuth { client_credentials: { assertion: string; assertion_type: string; } | { id: string; secret: string; } | + { id: string; } | null = null; token: TokenData | null = null; @@ -543,6 +554,10 @@ export class NxapiZncaAuth { assertion_type: process.env.NXAPI_ZNCA_API_CLIENT_ASSERTION_TYPE ?? 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', }; + } else if (process.env.NXAPI_ZNCA_API_CLIENT_ID) { + auth.client_credentials = { + id: process.env.NXAPI_ZNCA_API_CLIENT_ID, + }; } else if (client_assertion_provider) { auth.client_assertion_provider = client_assertion_provider; } else { @@ -677,7 +692,7 @@ export class NxapiZncaAuth { if (resource.search) debugZncaAuth('resource identifier contains search parameters'); if (resource.hash) throw new TypeError('Resource identifier contains fragment'); - debugZncaAuth('fetching protected resource metadata for %s', resource.href); + debugZncaAuth('fetching protected resource metadata for %s', this.getIssuerFromUrl(resource)); const metadata_url = new URL(resource); @@ -710,7 +725,7 @@ export class NxapiZncaAuth { if (issuer.search) debugZncaAuth('issuer identifier contains search parameters'); if (issuer.hash) throw new TypeError('Issuer identifier contains fragment'); - debugZncaAuth('fetching authorisation server metadata for %s', issuer.href); + debugZncaAuth('fetching authorisation server metadata for %s', this.getIssuerFromUrl(issuer)); const metadata_url = new URL(issuer); @@ -736,6 +751,12 @@ export class NxapiZncaAuth { return result; } + + getIssuerFromUrl(issuer: URL) { + if (issuer.search || issuer.hash) return issuer.href; + if (issuer.pathname !== '/') return issuer.href; + return issuer.origin; + } } export type FResult = { diff --git a/src/app/main/index.ts b/src/app/main/index.ts index 95ef37a..a363ba8 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -22,6 +22,7 @@ import createDebug from '../../util/debug.js'; import { dev, dir, git, release, version } from '../../util/product.js'; import { addUserAgent } from '../../util/useragent.js'; import { initStorage, paths } from '../../util/storage.js'; +import { ClientAssertionProvider, NXAPI_AUTH_APP_CLIENT_ID, setClientAssertionProvider } from '../../util/nxapi-auth.js'; import createI18n, { languages } from '../i18n/index.js'; import { CoralApiInterface } from '../../api/coral.js'; import { StatusUpdateIdentifierSymbol, StatusUpdateMonitor, StatusUpdateNotify, StatusUpdateResult, StatusUpdateSubscriber } from '../../common/status.js'; @@ -166,6 +167,7 @@ export async function init() { initGlobals(); addUserAgent('nxapi-app (Chromium ' + process.versions.chrome + '; Electron ' + process.versions.electron + ')'); + setClientAssertionProvider(new ClientAssertionProvider(NXAPI_AUTH_APP_CLIENT_ID)); setAboutPanelOptions(); diff --git a/src/cli.ts b/src/cli.ts index 02ee2c8..9d8f1f5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { addUserAgent } from './util/useragent.js'; import { USER_AGENT_INFO_URL } from './common/constants.js'; import { init as initGlobals } from './common/globals.js'; import { buildEnvironmentProxyAgent } from './util/undici-proxy.js'; +import { ClientAssertionProvider, NXAPI_AUTH_CLI_CLIENT_ID, setClientAssertionProvider } from './util/nxapi-auth.js'; const debug = createDebug('cli'); @@ -58,6 +59,8 @@ export async function main(argv = process.argv.slice(2)) { addUserAgent('unidentified-script'); } + setClientAssertionProvider(new ClientAssertionProvider(NXAPI_AUTH_CLI_CLIENT_ID)); + const yargs = createYargs(argv); if (!process.env.NXAPI_SKIP_UPDATE_CHECK) await checkUpdates(); diff --git a/src/exports/coral.ts b/src/exports/coral.ts index 97ccc34..2e102ca 100644 --- a/src/exports/coral.ts +++ b/src/exports/coral.ts @@ -21,9 +21,9 @@ export { default as ZncProxyApi } from '../api/znc-proxy.js'; export { ZncaApi, HashMethod, + createZncaApi, getPreferredZncaApiFromEnvironment, getDefaultZncaApi, - f, ZncaApiFlapg, FlapgIid, diff --git a/src/util/jwt.ts b/src/util/jwt.ts index 6323075..6d822f5 100644 --- a/src/util/jwt.ts +++ b/src/util/jwt.ts @@ -1,4 +1,4 @@ -import * as crypto from 'node:crypto'; +import { createHmac, createSign, createVerify, KeyLike, timingSafeEqual } from 'node:crypto'; import { Buffer } from 'node:buffer'; import persist from 'node-persist'; import { fetch } from 'undici'; @@ -21,6 +21,7 @@ export interface JwtHeader { } export enum JwtAlgorithm { RS256 = 'RS256', + HS256 = 'HS256', } export interface JwtPayload { @@ -40,7 +41,8 @@ export interface JwtPayload { typ: string; } -type JwtVerifier = (data: Buffer, signature: Buffer, key: string) => boolean; +type JwtVerifier = (data: Buffer, signature: Buffer, key: KeyLike) => boolean; +type JwtSigner = (data: Buffer, key: KeyLike) => Buffer; export class Jwt { constructor( @@ -63,7 +65,7 @@ export class Jwt { return [jwt, signature] as const; } - verify(signature: Buffer, key: string, verifier?: JwtVerifier) { + verify(signature: Buffer, key: KeyLike, verifier?: JwtVerifier) { const header_str = Buffer.from(JSON.stringify(this.header)).toString('base64url'); const payload_str = Buffer.from(JSON.stringify(this.payload)).toString('base64url'); const sign_data = header_str + '.' + payload_str; @@ -81,10 +83,45 @@ export class Jwt { static verifiers: Record = { [JwtAlgorithm.RS256]: (data, signature, key) => { - const verify = crypto.createVerify('RSA-SHA256'); - verify.end(data); + const verify = createVerify('RSA-SHA256'); + verify.update(data); return verify.verify(key, signature); }, + [JwtAlgorithm.HS256]: (data, signature, key) => { + const hmac = createHmac('sha256', key); + hmac.update(data); + return timingSafeEqual(signature, hmac.digest()); + }, + }; + + sign(key: KeyLike, signer?: JwtSigner) { + const header_str = Buffer.from(JSON.stringify(this.header)).toString('base64url'); + const payload_str = Buffer.from(JSON.stringify(this.payload)).toString('base64url'); + const sign_data = header_str + '.' + payload_str; + + if (!signer) { + if (!(this.header.alg in Jwt.signers) || !Jwt.signers[this.header.alg]) { + throw new Error('Unknown algorithm'); + } + + signer = Jwt.signers[this.header.alg]; + } + + const signature = signer.call(null, Buffer.from(sign_data), key); + return sign_data + '.' + signature.toString('base64url'); + } + + static signers: Record = { + [JwtAlgorithm.RS256]: (data, key) => { + const sign = createSign('RSA-SHA256'); + sign.update(data); + return sign.sign(key); + }, + [JwtAlgorithm.HS256]: (data, key) => { + const hmac = createHmac('sha256', key); + hmac.update(data); + return hmac.digest(); + }, }; } diff --git a/src/util/nxapi-auth.ts b/src/util/nxapi-auth.ts index 6387298..c6b3503 100644 --- a/src/util/nxapi-auth.ts +++ b/src/util/nxapi-auth.ts @@ -1,3 +1,7 @@ +import { createHash } from 'node:crypto'; +import { Jwt, JwtAlgorithm } from './jwt.js'; +import { dev, git, pkg, release } from './product.js'; + export const NXAPI_AUTH_CLI_CLIENT_ID = 'CKtknJ6HiH2AZIMw-x8ljw'; export const NXAPI_AUTH_APP_CLIENT_ID = 'GlR_qsPZpNcxqMwnbsSjMA'; @@ -14,3 +18,65 @@ export interface OAuthClientAssertion { assertion: string; type: string; } + +export class ClientAssertionProvider implements ClientAssertionProviderInterface { + constructor( + readonly client_id: string, + // readonly iss = 'nxapi', + readonly iss = client_id, + ) {} + + async create(aud: string, exp = 60) { + const assertion = await this.createAssertion(aud, exp); + const type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + + return { assertion, type }; + } + + async createAssertion(aud: string, exp = 60) { + const jwt = this.createAssertionJwt(aud, exp); + const key = await this.createAssertionKey(); + + return jwt.sign(key); + } + + createAssertionJwt(aud: string, exp = 60) { + const now = Math.floor(Date.now() / 1000); + + const data = Buffer.alloc(22 + (release?.length ?? 0)); + + if (git) Buffer.from(git.revision, 'hex').copy(data, 1, 0, 20); + if (dev) data[21] |= 1; + if (release) data.write(release, 22); + + return new Jwt({ + alg: JwtAlgorithm.HS256, + typ: 'JWT', + }, { + typ: 'client_assertion', + iss: this.iss, + aud, + exp: now + exp, + iat: now, + nxapi: data.toString('base64'), + }); + } + + async createAssertionKey() { + if (release) { + const hash = createHash('sha256'); + + const ts = Buffer.alloc(8); + ts.writeUint32BE(Math.floor(Date.now() / 10000)); + hash.update(ts); + + hash.update(JSON.stringify(pkg), 'utf-8'); + + const digest = hash.digest(); + + return digest; + } + + return Buffer.alloc(32); + } +}