feat(js): add common modules

This commit is contained in:
nazeh
2024-07-24 18:02:41 +03:00
parent 5cdf299f1a
commit 979882b443
8 changed files with 476 additions and 0 deletions

6
js/pubky/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
coverage
node_modules
types
.storage
.env
package-lock.json

51
js/pubky/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "@synonymdev/pubky",
"version": "0.1.0",
"description": "Pubky client library",
"type": "module",
"main": "src/index.js",
"types": "types/src/index.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/slashtags/skunk-works.git"
},
"scripts": {
"build": "tsc",
"clean": "rm -rf types",
"lint": "standard --fix",
"test": "brittle test/*.js -cov",
"depcheck": "npx depcheck --ignore-dirs=test",
"fullcheck": "npm run lint && npm run clean && npm run build && npm run test && npm run depcheck",
"prepublishOnly": "npm run fullcheck"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/pubky/pubky/issues"
},
"homepage": "https://github.com/pubky/pubky/tree/master/js/pubky/#readme",
"files": [
"src",
"types",
"!**/*.tsbuildinfo"
],
"dependencies": {
"blake3-wasm": "^3.0.0",
"crockford-base32": "^2.0.0",
"eventsource": "^2.0.2",
"hash-wasm": "^4.11.0",
"node-fetch-cache": "^4.1.2",
"pkarr": "^1.4.1",
"z32": "^1.1.0"
},
"browser": {
"./src/lib/fetch.js": "./src/lib/fetch-browser.js"
},
"devDependencies": {
"standard": "^17.1.0",
"typescript": "^5.5.4"
},
"overrides": {
"blake3-wasm@2.1.7": "^3.0.0",
"@c4312/blake3-internal": "^3.0.0"
}
}

194
js/pubky/src/common/auth.js Normal file
View File

@@ -0,0 +1,194 @@
import { Timestamp } from './timestamp.js'
import * as namespaces from './namespaces.js'
import * as crypto from './crypto.js'
// 30 seconds
const TIME_INTERVAL = 30 * 1000000
export class AuthnSignature {
/**
* @param {number} time
* @param {import ('./crypto.js').KeyPair} signer
* @param {import ('./crypto.js').PublicKey} audience
* @param {Buffer} [token]
*/
constructor (time, signer, audience, token = crypto.randomBytes()) {
const timeStep = Math.floor(time / TIME_INTERVAL)
const tokenHash = crypto.hash(token)
const timeStepBytes = Buffer.allocUnsafe(8)
timeStepBytes.writeBigUint64BE(BigInt(timeStep))
const signature = signer.sign(signable(
signer.publicKey().bytes,
audience.bytes,
timeStepBytes,
tokenHash
))
this.bytes = Buffer.concat([
signature,
tokenHash
])
}
/**
* @param {import ('./crypto.js').KeyPair} signer
* @param {import ('./crypto.js').PublicKey} audience
* @param {Buffer} [token]
*/
static sign (signer, audience, token = crypto.randomBytes()) {
const time = Timestamp.now().microseconds
return new AuthnSignature(time, signer, audience, token)
}
asBytes () {
return new Uint8Array(this.bytes)
}
}
export class AuthnVerifier {
#audience
/** @type {Array<Buffer>} */
#seen
/**
* @param {crypto.PublicKey} audience
*/
constructor (audience) {
this.#audience = audience
this.#seen = []
}
#gc () {
const threshold = Timestamp.now().microseconds
const threshouldStep = Math.floor(threshold / TIME_INTERVAL) - 2
const thresholdBytes = Buffer.allocUnsafe(8)
thresholdBytes.writeBigUint64BE(BigInt(threshouldStep))
let count = 0
for (let i = 0; i < this.#seen.length; i++) {
if (this.#seen[i].subarray(0, 8).compare(thresholdBytes) > 0) {
break
}
count = i
}
this.#seen.splice(0, count)
}
/**
* @param {Buffer} bytes
* @param {crypto.PublicKey} signer
*
* @returns {true | Error}
*/
verify (bytes, signer) {
this.#gc()
if (bytes.length !== 96) {
throw new Error(`InvalidLength: ${bytes.length}`)
}
const signature = bytes.subarray(0, 64)
const tokenHash = bytes.subarray(64)
const now = Timestamp.now().microseconds
const past = now - TIME_INTERVAL
const future = now + TIME_INTERVAL
let result = verifyAt.call(this, now)
if (!(result instanceof Error)) {
return result
} else if (result.toString() === 'Error: AuthnSignature already used') {
return result
}
result = verifyAt.call(this, past)
if (!(result instanceof Error)) {
return result
} else if (result.toString() === 'Error: AuthnSignature already used') {
return result
}
return verifyAt.call(this, future)
/**
* @param {number} time
*/
function verifyAt (time) {
const timeStep = Math.floor(time / TIME_INTERVAL)
const timeStepBytes = Buffer.allocUnsafe(8)
timeStepBytes.writeBigUint64BE(BigInt(timeStep))
const result = signer.verify(signature, signable(signer.bytes, this.#audience.bytes, timeStepBytes, tokenHash))
const candidate = Buffer.concat([
timeStepBytes,
tokenHash
])
if (!(result instanceof Error)) {
const index = binarySearch(this.#seen, timeStepBytes)
if (this.#seen[index]?.equals(candidate)) {
return new Error('AuthnSignature already used')
}
this.#seen.splice(~index, 0, candidate)
return
}
return result
}
}
}
/**
* @param {Array<Buffer>} arr
*/
function binarySearch (arr, element) {
let left = 0
let right = arr.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
const comparison = arr[mid].subarray(0, 8).compare(element.subarray(0, 8))
if (comparison === 0) {
return mid
} else if (comparison < 0) {
left = mid + 1
} else {
right = mid - 1
}
}
return left // Element not found, return the index where it should be inserted
}
/**
* @param {Buffer} signer
* @param {Buffer} audience
* @param {Buffer} timeStepBytes
* @param {Buffer} tokenHash
*/
function signable (signer, audience, timeStepBytes, tokenHash) {
return Buffer.concat([
namespaces.PUBKY_AUTHN,
timeStepBytes,
signer,
audience,
tokenHash
])
}

View File

@@ -0,0 +1,131 @@
//! Crypeo functions
import sodium from 'sodium-universal'
import z32 from 'z32'
// Blake3
/** @type {import('blake3-wasm')} */
let loadedBlake3
const loadBlake3 = async () => {
if (loadedBlake3) return loadedBlake3
// @ts-ignore
loadedBlake3 = await import('blake3-wasm').then(b3 => b3.load().then(() => b3))
return loadedBlake3
}
loadBlake3()
/**
* It will return null if blake3 is not loaded yet!
*
* @param {Buffer} message
*
* @returns {Buffer | null}
*/
export const hash = (message) => {
return loadedBlake3?.createHash().update(message).digest()
}
// Random
export const randomBytes = (n = 32) => {
const buf = Buffer.alloc(n)
sodium.randombytes_buf(buf)
return buf
}
/// Keypairs
/**
* @param {Buffer} buf
*/
export const zeroize = (buf) => {
buf.fill(0)
}
export class KeyPair {
#publicKey
#secretKey
/**
* @param {Buffer} seed
*/
constructor (seed) {
this.#publicKey = Buffer.allocUnsafe(sodium.crypto_sign_PUBLICKEYBYTES)
this.#secretKey = Buffer.allocUnsafe(sodium.crypto_sign_SECRETKEYBYTES)
if (seed) sodium.crypto_sign_seed_keypair(this.#publicKey, this.#secretKey, seed)
else sodium.crypto_sign_keypair(this.#publicKey, this.#secretKey)
}
static random () {
const seed = randomBytes(32)
return new KeyPair(seed)
}
zeroize () {
zeroize(this.#secretKey)
this.secretKey = null
}
publicKey () {
return new PublicKey(this.#publicKey)
}
secretKey () {
return this.#secretKey
}
/**
* @param {Uint8Array} message
*/
sign (message) {
const signature = Buffer.alloc(sodium.crypto_sign_BYTES)
sodium.crypto_sign_detached(signature, message, this.#secretKey)
return signature
}
}
export class PublicKey {
/**
* @param {Buffer} bytes
*/
constructor (bytes) {
this.bytes = bytes
}
/**
* @param {string} string
* @returns {Error | PublicKey}
*/
static fromString (string) {
if (string.length !== 52) {
return new Error('Invalid PublicKey string, expected 52 characters, got: ' + string.length)
}
try {
return new PublicKey(z32.decode(string))
} catch (error) {
return error
}
}
/**
* @param {Buffer} signature
* @param {Buffer} message
*/
verify (signature, message) {
const valid = sodium.crypto_sign_verify_detached(signature, message, this.bytes)
if (!valid) return new Error('Invalid signature')
return true
}
toString () {
return z32.encode(this.bytes)
}
}

View File

@@ -0,0 +1,8 @@
export * as crypto from './crypto.js'
export { Timestamp } from './timestamp.js'
export { AuthnSignature, AuthnVerifier } from './auth.js'
/**
* @typedef {string | number | boolean | null} JSONValue
* @typedef {{[key: string]: JSONValue | Array<JSONValue>}} JSONObject
*/

View File

@@ -0,0 +1 @@
export const PUBKY_AUTHN = Buffer.from('PUBKY:AUTHN')

View File

@@ -0,0 +1,53 @@
import { CrockfordBase32 } from 'crockford-base32'
import { randomBytes } from './crypto.js'
const clockId = randomBytes(1).readUintBE(0, 1)
let latest = 0
export class Timestamp {
/**
* @param {number} microseconds - u64 microseconds
*/
constructor (microseconds) {
/** microseconds as u64 */
this.microseconds = microseconds
}
static now () {
const now = Date.now()
latest = Math.max(now, latest + 1)
return new Timestamp((latest * 1000) + clockId)
}
/**
* @param {string} string
*/
static fromString (string) {
const microseconds = Number(CrockfordBase32.decode(string, { asNumber: true }))
return new Timestamp(microseconds)
}
/**
* @param {Date} date
*/
static fromDate (date) {
const microseconds = Number(date) * 1000
return new Timestamp(microseconds)
}
toString () {
return CrockfordBase32.encode(this.microseconds)
}
toDate () {
return new Date(this.microseconds / 1000)
}
intoBytes () {
const buffer = Buffer.allocUnsafe(8)
buffer.writeBigUint64BE(BigInt(this.microseconds), 0)
return buffer
}
}

32
js/pubky/tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
// Declarations control
"target": "esnext",
"module": "esnext",
"noEmitOnError": true,
"emitDeclarationOnly": true,
"declarationMap": true,
"isolatedModules": true,
"incremental": true,
"composite": true,
// Check control
"strict": false,
"allowJs": true,
"checkJs": true,
// module resolution
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
// advanced
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"outDir": "types"
},
"include": ["src", "lib"]
}