diff --git a/js/pubky/.gitignore b/js/pubky/.gitignore new file mode 100644 index 0000000..6da14b1 --- /dev/null +++ b/js/pubky/.gitignore @@ -0,0 +1,6 @@ +coverage +node_modules +types +.storage +.env +package-lock.json diff --git a/js/pubky/package.json b/js/pubky/package.json new file mode 100644 index 0000000..08a46cf --- /dev/null +++ b/js/pubky/package.json @@ -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" + } +} diff --git a/js/pubky/src/common/auth.js b/js/pubky/src/common/auth.js new file mode 100644 index 0000000..f48d387 --- /dev/null +++ b/js/pubky/src/common/auth.js @@ -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} */ + #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} 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 + ]) +} diff --git a/js/pubky/src/common/crypto.js b/js/pubky/src/common/crypto.js new file mode 100644 index 0000000..c1c81b0 --- /dev/null +++ b/js/pubky/src/common/crypto.js @@ -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) + } +} diff --git a/js/pubky/src/common/index.js b/js/pubky/src/common/index.js new file mode 100644 index 0000000..71ccb60 --- /dev/null +++ b/js/pubky/src/common/index.js @@ -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}} JSONObject + */ diff --git a/js/pubky/src/common/namespaces.js b/js/pubky/src/common/namespaces.js new file mode 100644 index 0000000..156d3af --- /dev/null +++ b/js/pubky/src/common/namespaces.js @@ -0,0 +1 @@ +export const PUBKY_AUTHN = Buffer.from('PUBKY:AUTHN') diff --git a/js/pubky/src/common/timestamp.js b/js/pubky/src/common/timestamp.js new file mode 100644 index 0000000..eeeadec --- /dev/null +++ b/js/pubky/src/common/timestamp.js @@ -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 + } +} diff --git a/js/pubky/tsconfig.json b/js/pubky/tsconfig.json new file mode 100644 index 0000000..63d7a71 --- /dev/null +++ b/js/pubky/tsconfig.json @@ -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"] +}