f-generation/request encryption API, allow only client ID, add nxapi-cli/app client authentication

This commit is contained in:
Samuel Elliott 2025-07-22 17:14:41 +01:00
parent e1e2a2223e
commit 9339eb73fc
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
6 changed files with 143 additions and 14 deletions

View File

@ -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<FResult> {
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 = {

View File

@ -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();

View File

@ -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();

View File

@ -21,9 +21,9 @@ export { default as ZncProxyApi } from '../api/znc-proxy.js';
export {
ZncaApi,
HashMethod,
createZncaApi,
getPreferredZncaApiFromEnvironment,
getDefaultZncaApi,
f,
ZncaApiFlapg,
FlapgIid,

View File

@ -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<T = JwtPayload, H extends JwtHeader = JwtHeader> {
constructor(
@ -63,7 +65,7 @@ export class Jwt<T = JwtPayload, H extends JwtHeader = JwtHeader> {
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<T = JwtPayload, H extends JwtHeader = JwtHeader> {
static verifiers: Record<JwtAlgorithm, JwtVerifier> = {
[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, JwtSigner> = {
[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();
},
};
}

View File

@ -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);
}
}