chore: rename fundamentals to base (#9119)

This commit is contained in:
forehalo
2024-12-13 06:27:12 +00:00
parent 8c24f2b906
commit 4c23991047
185 changed files with 183 additions and 193 deletions
@@ -0,0 +1,98 @@
import { createPrivateKey, createPublicKey } from 'node:crypto';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { CryptoHelper } from '../crypto';
const test = ava as TestFn<{
crypto: CryptoHelper;
}>;
const key = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
-----END EC PRIVATE KEY-----`;
const privateKey = createPrivateKey({
key,
format: 'pem',
type: 'sec1',
})
.export({
type: 'pkcs8',
format: 'pem',
})
.toString('utf8');
const publicKey = createPublicKey({
key,
format: 'pem',
type: 'spki',
})
.export({
format: 'pem',
type: 'spki',
})
.toString('utf8');
test.beforeEach(async t => {
t.context.crypto = new CryptoHelper({
crypto: {
secret: {
publicKey,
privateKey,
},
},
} as any);
});
test('should be able to sign and verify', t => {
const data = 'hello world';
const signature = t.context.crypto.sign(data);
t.true(t.context.crypto.verify(data, signature));
t.false(t.context.crypto.verify(data, 'fake-signature'));
});
test('should be able to encrypt and decrypt', t => {
const data = 'top secret';
const stub = Sinon.stub(t.context.crypto, 'randomBytes').returns(
Buffer.alloc(12, 0)
);
const encrypted = t.context.crypto.encrypt(data);
const decrypted = t.context.crypto.decrypt(encrypted);
// we are using a stub to make sure the iv is always 0,
// the encrypted result will always be the same
t.is(encrypted, 'AAAAAAAAAAAAAAAAWUDlJRhzP+SZ3avvmLcgnou+q4E11w==');
t.is(decrypted, data);
stub.restore();
});
test('should be able to get random bytes', t => {
const bytes = t.context.crypto.randomBytes();
t.is(bytes.length, 12);
const bytes2 = t.context.crypto.randomBytes();
t.notDeepEqual(bytes, bytes2);
});
test('should be able to digest', t => {
const data = 'hello world';
const hash = t.context.crypto.sha256(data).toString('base64');
t.is(hash, 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=');
});
test('should be able to safe compare', t => {
t.true(t.context.crypto.compare('abc', 'abc'));
t.false(t.context.crypto.compare('abc', 'def'));
});
test('should be able to hash and verify password', async t => {
const password = 'mySecurePassword';
const hash = await t.context.crypto.encryptPassword(password);
t.true(await t.context.crypto.verifyPassword(password, hash));
t.false(await t.context.crypto.verifyPassword('wrong-password', hash));
});
@@ -0,0 +1,108 @@
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { URLHelper } from '../url';
const test = ava as TestFn<{
url: URLHelper;
}>;
test.beforeEach(async t => {
t.context.url = new URLHelper({
server: {
externalUrl: '',
host: 'app.affine.local',
port: 3010,
https: true,
path: '',
},
} as any);
});
test('can factor base url correctly without specified external url', t => {
t.is(t.context.url.baseUrl, 'https://app.affine.local');
});
test('can factor base url correctly with specified external url', t => {
const url = new URLHelper({
server: {
externalUrl: 'https://external.domain.com',
host: 'app.affine.local',
port: 3010,
https: true,
path: '/ignored',
},
} as any);
t.is(url.baseUrl, 'https://external.domain.com');
});
test('can factor base url correctly with specified external url and path', t => {
const url = new URLHelper({
server: {
externalUrl: 'https://external.domain.com/anything',
host: 'app.affine.local',
port: 3010,
https: true,
path: '/ignored',
},
} as any);
t.is(url.baseUrl, 'https://external.domain.com/anything');
});
test('can factor base url correctly with specified external url with port', t => {
const url = new URLHelper({
server: {
externalUrl: 'https://external.domain.com:123',
host: 'app.affine.local',
port: 3010,
https: true,
},
} as any);
t.is(url.baseUrl, 'https://external.domain.com:123');
});
test('can stringify query', t => {
t.is(t.context.url.stringify({ a: 1, b: 2 }), 'a=1&b=2');
t.is(t.context.url.stringify({ a: 1, b: '/path' }), 'a=1&b=%2Fpath');
});
test('can create link', t => {
t.is(t.context.url.link('/path'), 'https://app.affine.local/path');
t.is(
t.context.url.link('/path', { a: 1, b: 2 }),
'https://app.affine.local/path?a=1&b=2'
);
t.is(
t.context.url.link('/path', { a: 1, b: '/path' }),
'https://app.affine.local/path?a=1&b=%2Fpath'
);
});
test('can safe redirect', t => {
const res = {
redirect: (to: string) => to,
} as any;
const spy = Sinon.spy(res, 'redirect');
function allow(to: string) {
t.context.url.safeRedirect(res, to);
t.true(spy.calledOnceWith(to));
spy.resetHistory();
}
function deny(to: string) {
t.context.url.safeRedirect(res, to);
t.true(spy.calledOnceWith(t.context.url.home));
spy.resetHistory();
}
[
'https://app.affine.local',
'https://app.affine.local/path',
'https://app.affine.local/path?query=1',
].forEach(allow);
['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
});
@@ -0,0 +1,53 @@
import { createPrivateKey, createPublicKey } from 'node:crypto';
import { defineStartupConfig, ModuleConfig } from '../config';
declare module '../config' {
interface AppConfig {
crypto: ModuleConfig<{
secret: {
publicKey: string;
privateKey: string;
};
}>;
}
}
// Don't use this in production
const examplePrivateKey = `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
-----END EC PRIVATE KEY-----`;
defineStartupConfig('crypto', {
secret: (function () {
const AFFINE_PRIVATE_KEY =
process.env.AFFINE_PRIVATE_KEY ?? examplePrivateKey;
const privateKey = createPrivateKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'sec1',
})
.export({
format: 'pem',
type: 'pkcs8',
})
.toString('utf8');
const publicKey = createPublicKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
format: 'pem',
type: 'spki',
})
.export({
format: 'pem',
type: 'spki',
})
.toString('utf8');
return {
publicKey,
privateKey,
};
})(),
});
@@ -0,0 +1,115 @@
import {
createCipheriv,
createDecipheriv,
createHash,
createSign,
createVerify,
randomBytes,
timingSafeEqual,
} from 'node:crypto';
import { Injectable } from '@nestjs/common';
import {
hash as hashPassword,
verify as verifyPassword,
} from '@node-rs/argon2';
import { Config } from '../config';
const NONCE_LENGTH = 12;
const AUTH_TAG_LENGTH = 12;
@Injectable()
export class CryptoHelper {
keyPair: {
publicKey: Buffer;
privateKey: Buffer;
sha256: {
publicKey: Buffer;
privateKey: Buffer;
};
};
constructor(config: Config) {
this.keyPair = {
publicKey: Buffer.from(config.crypto.secret.publicKey, 'utf8'),
privateKey: Buffer.from(config.crypto.secret.privateKey, 'utf8'),
sha256: {
publicKey: this.sha256(config.crypto.secret.publicKey),
privateKey: this.sha256(config.crypto.secret.privateKey),
},
};
}
sign(data: string) {
const sign = createSign('rsa-sha256');
sign.update(data, 'utf-8');
sign.end();
return sign.sign(this.keyPair.privateKey, 'base64');
}
verify(data: string, signature: string) {
const verify = createVerify('rsa-sha256');
verify.update(data, 'utf-8');
verify.end();
return verify.verify(this.keyPair.privateKey, signature, 'base64');
}
encrypt(data: string) {
const iv = this.randomBytes();
const cipher = createCipheriv(
'aes-256-gcm',
this.keyPair.sha256.privateKey,
iv,
{
authTagLength: AUTH_TAG_LENGTH,
}
);
const encrypted = Buffer.concat([
cipher.update(data, 'utf-8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
decrypt(encrypted: string) {
const buf = Buffer.from(encrypted, 'base64');
const iv = buf.subarray(0, NONCE_LENGTH);
const authTag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + AUTH_TAG_LENGTH);
const encryptedToken = buf.subarray(NONCE_LENGTH + AUTH_TAG_LENGTH);
const decipher = createDecipheriv(
'aes-256-gcm',
this.keyPair.sha256.privateKey,
iv,
{ authTagLength: AUTH_TAG_LENGTH }
);
decipher.setAuthTag(authTag);
const decrepted = decipher.update(encryptedToken, void 0, 'utf8');
return decrepted + decipher.final('utf8');
}
encryptPassword(password: string) {
return hashPassword(password);
}
verifyPassword(password: string, hash: string) {
return verifyPassword(hash, password);
}
compare(lhs: string, rhs: string) {
if (lhs.length !== rhs.length) {
return false;
}
return timingSafeEqual(Buffer.from(lhs), Buffer.from(rhs));
}
randomBytes(length = NONCE_LENGTH) {
return randomBytes(length);
}
sha256(data: string) {
return createHash('sha256').update(data).digest();
}
}
@@ -0,0 +1,15 @@
import './config';
import { Global, Module } from '@nestjs/common';
import { CryptoHelper } from './crypto';
import { URLHelper } from './url';
@Global()
@Module({
providers: [URLHelper, CryptoHelper],
exports: [URLHelper, CryptoHelper],
})
export class HelpersModule {}
export { CryptoHelper, URLHelper };
@@ -0,0 +1,96 @@
import { isIP } from 'node:net';
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { Config } from '../config';
@Injectable()
export class URLHelper {
private readonly redirectAllowHosts: string[];
readonly origin: string;
readonly baseUrl: string;
readonly home: string;
constructor(private readonly config: Config) {
if (this.config.server.externalUrl) {
if (!this.verify(this.config.server.externalUrl)) {
throw new Error(
'Invalid `server.externalUrl` configured. It must be a valid url.'
);
}
const externalUrl = new URL(this.config.server.externalUrl);
this.origin = externalUrl.origin;
this.baseUrl =
externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
} else {
this.origin = [
this.config.server.https ? 'https' : 'http',
'://',
this.config.server.host,
this.config.server.host === 'localhost' || isIP(this.config.server.host)
? `:${this.config.server.port}`
: '',
].join('');
this.baseUrl = this.origin + this.config.server.path;
}
this.home = this.baseUrl;
this.redirectAllowHosts = [this.baseUrl];
}
stringify(query: Record<string, any>) {
return new URLSearchParams(query).toString();
}
url(path: string, query: Record<string, any> = {}) {
const url = new URL(path, this.origin);
for (const key in query) {
url.searchParams.set(key, query[key]);
}
return url;
}
link(path: string, query: Record<string, any> = {}) {
return this.url(path, query).toString();
}
safeRedirect(res: Response, to: string) {
try {
const finalTo = new URL(decodeURIComponent(to), this.baseUrl);
for (const host of this.redirectAllowHosts) {
const hostURL = new URL(host);
if (
hostURL.origin === finalTo.origin &&
finalTo.pathname.startsWith(hostURL.pathname)
) {
return res.redirect(finalTo.toString().replace(/\/$/, ''));
}
}
} catch {
// just ignore invalid url
}
// redirect to home if the url is invalid
return res.redirect(this.home);
}
verify(url: string | URL) {
try {
if (typeof url === 'string') {
url = new URL(url);
}
if (!['http:', 'https:'].includes(url.protocol)) return false;
if (!url.hostname) return false;
return true;
} catch {
return false;
}
}
}