Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package-lock.json
node_modules
**/.env
.vercel
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,26 @@
# serverless-vercel-redis
Securelay API Implementation as Serverless Function for hosting on Vercel. Uses Redis as DB. Work In Progress.
# About
This is a serverless, NodeJS implementation of the [Securelay API](https://github.com/securelay/specs).
It uses [Redis](https://redis.io/docs/latest/commands/) as database.
This particular implementation is configured to be hosted on [Vercel](https://vercel.com/pricing)
out of the box. However, with a few [variations](https://fastify.dev/docs/latest/Guides/Serverless/),
this implmentation may be run on any serverless platform such as [AWS Lambda](https://fastify.dev/docs/latest/Guides/Serverless/#aws),
provided a Redis DB, such as [Upstash](https://upstash.com/pricing/redis), can be used.

# How to host on Vercel

### Using GUI
- [Create](https://vercel.com/signup) a Vercel account.
- Get Redis in the form of [Vercel's KV store](https://vercel.com/docs/storage/vercel-kv/quickstart).
- Import [this](https://github.com/securelay/api-serverless-redis-vercel) project and deploy it by following [this tutorial](https://vercel.com/docs/getting-started-with-vercel/import).
- Note: Before deploying, set the environment variables through Vercel's Project Settings page by following the template provided in [example.env](./example.env).

### Using CLI
- [Create](https://vercel.com/signup) a Vercel account.
- Get Redis in the form of [Vercel's KV store](https://vercel.com/docs/storage/vercel-kv/quickstart).
- Set the environment variables through Vercel's Project Settings page by following the template provided in [example.env](./example.env).
- Get vercel CLI: `npm i -g vercel`.
- Clone this repo: `git clone https://github.com/securelay/api-serverless-redis-vercel`.
- `cd api-serverless-redis-vercel`.
- `vercel login`.
- Deploy locally and test: `vercel dev`.
- If everything is working fine deploy publicly on Vercel: `vercel`.
98 changes: 98 additions & 0 deletions api/helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Crypto from 'node:crypto';
import { kv } from '@vercel/kv';

const secret = process.env.SECRET;
const sigLen = parseInt(process.env.SIG_LEN);
const hashLen = parseInt(process.env.HASH_LEN);
const ttl = parseInt(process.env.TTL);
const dbKeyPrefix = {
manyToOne: "m2o:",
oneToMany: "o2m:",
oneToOne: "o2o:",
}

function hash(str){
return Crypto.hash('md5', str, 'base64url').substr(0,hashLen);
// For small size str Crypto.hash() is faster than Crypto.createHash()
}

function sign(str){
// Note: https://nodejs.org/api/crypto.html#using-strings-as-inputs-to-cryptographic-apis
return Crypto.createHmac('md5', secret).update(str).digest('base64url').substr(0,sigLen);
}

export function validate(key){
const sig = key.substr(0, sigLen);
const hash = key.substr(sigLen,);
if (sig === sign(hash + 'public')){
return 'public';
} else if (sig === sign(hash + 'private')){
return 'private';
} else {
return false;
}
}

export function genPublicKey(privateKey){
const privateHash = privateKey.substr(sigLen,);
const publicHash = hash(privateHash);
const publicKey = sign(publicHash + 'public') + publicHash;
return publicKey
}

export function genKeyPair(seed = Crypto.randomUUID()){
const privateHash = hash(seed);
const privateKey = sign(privateHash + 'private') + privateHash;
const publicKey = genPublicKey(privateKey);
return {private: privateKey, public: publicKey};
}

export async function publicProduce(publicKey, data){
const dbKey = dbKeyPrefix.manyToOne + publicKey;
return kv.rpush(dbKey, data).then(kv.expire(dbKey, ttl));
}

export async function privateConsume(privateKey){
const publicKey = genPublicKey(privateKey);
const dbKey = dbKeyPrefix.manyToOne + publicKey;
const llen = await kv.llen(dbKey);
if (!llen) return [];
return kv.lpop(dbKey, llen);
}

export async function privateProduce(privateKey, data){
const publicKey = genPublicKey(privateKey);
const dbKey = dbKeyPrefix.oneToMany + publicKey;
return kv.set(dbKey, data, { ex: ttl });
}

export async function publicConsume(publicKey){
const dbKey = dbKeyPrefix.oneToMany + publicKey;
return kv.get(dbKey);
}

export async function oneToOneProduce(privateKey, key, data){
const publicKey = genPublicKey(privateKey);
const dbKey = dbKeyPrefix.oneToOne + publicKey;
let field = {};
field[key] = data;
return kv.hset(dbKey, field).then(kv.expire(dbKey, ttl));
}

export async function oneToOneConsume(publicKey, key){
const dbKey = dbKeyPrefix.oneToOne + publicKey;
const field = key;
return kv.hget(dbKey, field).then(kv.hdel(dbKey, field));
}

export async function oneToOneIsConsumed(privateKey, key){
const publicKey = genPublicKey(privateKey);
const dbKey = dbKeyPrefix.oneToOne + publicKey;
const field = key;
const bool = await kv.hexists(dbKey, field);
if (bool) {
return "Not consumed yet.";
} else {
return "Consumed.";
}
}
180 changes: 180 additions & 0 deletions api/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import * as helper from './helper.js';
import Fastify from 'fastify';

const bodyLimit = parseInt(process.env.BODYLIMIT);
const fieldLimit = parseInt(process.env.FIELDLIMIT);

// Impose content-length limit
const fastify = Fastify({
ignoreTrailingSlash: true,
bodyLimit: bodyLimit
})

// Enable CORS. This implementation is lightweight than importing '@fastify/cors'
fastify.addHook('onRequest', (request, reply, done) => {
reply.headers({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST'
})
done();
})

// Enable parser for application/x-www-form-urlencoded
fastify.register(import('@fastify/formbody'))

const callUnauthorized = function(reply, msg){
reply.code(401).send({message: msg, error: "Unauthorized", statusCode: reply.statusCode});
}

const callBadRequest = function(reply, msg){
reply.code(400).send({message: msg, error: "Bad Request", statusCode: reply.statusCode});
}

const callInternalServerError = function(reply, msg){
reply.code(500).send({message: msg, error: "Internal Server Error", statusCode: reply.statusCode});
}

fastify.get('/', (request, reply) => {
reply.redirect('https://securelay.github.io', 301);
})

fastify.post('/', (request, reply) => {
reply.redirect('https://securelay.github.io', 301);
})

fastify.get('/keys', (request, reply) => {
reply.send(helper.genKeyPair());
})

fastify.get('/keys/:key', (request, reply) => {
const { key } = request.params;
const keyType = helper.validate(key);
if (keyType === 'public') {
reply.send({type: "public"});
} else if (keyType === 'private') {
reply.send({type: "private", public: helper.genPublicKey(key)});
} else {
reply.callNotFound();
}
})

fastify.post('/public/:publicKey', async (request, reply) => {
const { publicKey } = request.params;
try {
if (helper.validate(publicKey) !== 'public') throw 401;
await helper.publicProduce(publicKey, JSON.stringify(request.body));
reply.send({message: "Done", error: "Ok", statusCode: reply.statusCode});
} catch (err) {
if (err == 401) {
callUnauthorized(reply, 'Provided key is not Public');
} else {
callInternalServerError(reply, err);
}
}
})

fastify.get('/private/:privateKey', async (request, reply) => {
const { privateKey } = request.params;
try {
if (helper.validate(privateKey) !== 'private') throw 401;
const dataArray = await helper.privateConsume(privateKey);
if (!dataArray.length) throw 404;
reply.send(dataArray);
} catch (err) {
if (err == 401) {
callUnauthorized(reply, 'Provided key is not Private');
} else if (err == 404) {
reply.callNotFound();
} else {
callInternalServerError(reply, err);
}
}
})

fastify.post('/private/:privateKey', async (request, reply) => {
const { privateKey } = request.params;
try {
if (helper.validate(privateKey) !== 'private') throw 401;
await helper.privateProduce(privateKey, JSON.stringify(request.body));
reply.send({message: "Done", error: "Ok", statusCode: reply.statusCode});
} catch (err) {
if (err == 401) {
callUnauthorized(reply, 'Provided key is not Private');
} else {
callInternalServerError(reply, err);
}
}
})

fastify.get('/public/:publicKey', async (request, reply) => {
const { publicKey } = request.params;
try {
if (helper.validate(publicKey) !== 'public') throw 401;
const data = await helper.publicConsume(publicKey);
if (!data) throw 404;
reply.send(data);
} catch (err) {
if (err == 401) {
callUnauthorized(reply, 'Provided key is not Public');
} else if (err == 404) {
reply.callNotFound();
} else {
callInternalServerError(reply, err);
}
}
})

fastify.post('/private/:privateKey/:key', async (request, reply) => {
const { privateKey, key } = request.params;
if (key.substr(0,fieldLimit) !== key) {callBadRequest(reply, 'Provided field is too long'); return;}
try {
if (helper.validate(privateKey) !== 'private') throw 401;
await helper.oneToOneProduce(privateKey, key, JSON.stringify(request.body));
reply.send({message: "Done", error: "Ok", statusCode: reply.statusCode});
} catch (err) {
if (err == 401) {
callUnauthorized(reply, 'Provided key is not Private');
} else {
callInternalServerError(reply, err);
}
}
})

fastify.get('/public/:publicKey/:key', async (request, reply) => {
const { publicKey, key } = request.params;
if (key.substr(0,fieldLimit) !== key) {callBadRequest(reply, 'Provided field is too long'); return;}
try {
if (helper.validate(publicKey) !== 'public') throw 401;
const data = await helper.oneToOneConsume(publicKey, key);
if (!data) throw 404;
reply.send(data);
} catch (err) {
if (err == 401) {
callUnauthorized(reply, 'Provided key is not Public');
} else if (err == 404) {
reply.callNotFound();
} else {
callInternalServerError(reply, err);
}
}
})

fastify.get('/private/:privateKey/:key', async (request, reply) => {
const { privateKey, key } = request.params;
if (key.substr(0,fieldLimit) !== key) {callBadRequest(reply, 'Provided field is too long'); return;}
try {
if (helper.validate(privateKey) !== 'private') throw 401;
reply.send(await helper.oneToOneIsConsumed(privateKey, key));
} catch (err) {
if (err == 401) {
callUnauthorized(reply, 'Provided key is not Private');
} else {
callInternalServerError(reply, err);
}
}
})

export default async function handler(req, res) {
await fastify.ready();
fastify.server.emit('request', req, res);
}
30 changes: 30 additions & 0 deletions api/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
Brief: Testing
Run: node --env-file=.env test.js
*/

import * as helper from './helper.js';


const key = helper.genKeyPair();

console.log(JSON.stringify(key));

console.log('This should show public: ' + helper.validate(key.public));
console.log('This should show private: ' + helper.validate(key.private));
console.log('This should show false: ' + helper.validate('random'));

await helper.publicProduce(key.public, 'dataA hi"');
await helper.publicProduce(key.public, 'dataB hi"');
await helper.publicProduce(key.public, 'dataC hi"');

console.log(await helper.privateConsume(key.private));

await helper.privateProduce(key.private, 'data hi"');
console.log(await helper.publicConsume(key.public));

await helper.oneToOneProduce(key.private, 'some Key', 'data "for one to one at some key');
console.log(await helper.oneToOneConsume(key.public, 'some Key'));
console.log(await helper.oneToOneIsConsumed(key.private, 'some Key'));

console.log('End of synchronous execution. Anything logged after this is from async only!')
18 changes: 18 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# This is a template for the .env file.
# Edit the following values and rename this file to .env

# Crypto related
SECRET='SomeRandomString' # Server's secret used for signing
SIG_LEN=5 # Signature character count
HASH_LEN=5 # Hash character count

# Limits related
TTL=86400 # Time to live for data in seconds
BODYLIMIT=10000 # Max content-length in bytes. Note: 1 byte = 1 character.
FIELDLIMIT = 5 # Max length of the one-to-one token

# Redis credentials
KV_URL=
KV_REST_API_URL=
KV_REST_API_TOKEN=
KV_REST_API_READ_ONLY_TOKEN=
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/formbody": "^8.0.1",
"@vercel/kv": "^2.0.0",
"fastify": "^5.0.0"
},
"type": "module"
}
23 changes: 23 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"buildCommand": null,
"devCommand": null,
"framework": null,
"installCommand": "npm install",
"nodeVersion": "20.x",
"outputDirectory": null,
"rootDirectory": null,
"trailingSlash": false,
"redirects": [
{
"source": "/",
"destination": "https://securelay.github.io/",
"permanent": true
}
],
"rewrites": [
{
"source": "/(public|private|keys)/:path*",
"destination": "/api"
}
]
}