mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-03-21 18:04:10 -05:00
f-generation/request encryption API, allow only client ID, add nxapi-cli/app client authentication
This commit is contained in:
parent
e1e2a2223e
commit
9339eb73fc
37
src/api/f.ts
37
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<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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ export { default as ZncProxyApi } from '../api/znc-proxy.js';
|
|||
export {
|
||||
ZncaApi,
|
||||
HashMethod,
|
||||
createZncaApi,
|
||||
getPreferredZncaApiFromEnvironment,
|
||||
getDefaultZncaApi,
|
||||
f,
|
||||
|
||||
ZncaApiFlapg,
|
||||
FlapgIid,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user