mirror of
https://github.com/aljazceru/pubky-core.git
synced 2025-12-31 21:04:34 +01:00
feat(js): add common modules
This commit is contained in:
6
js/pubky/.gitignore
vendored
Normal file
6
js/pubky/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
coverage
|
||||
node_modules
|
||||
types
|
||||
.storage
|
||||
.env
|
||||
package-lock.json
|
||||
51
js/pubky/package.json
Normal file
51
js/pubky/package.json
Normal 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
194
js/pubky/src/common/auth.js
Normal 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
|
||||
])
|
||||
}
|
||||
131
js/pubky/src/common/crypto.js
Normal file
131
js/pubky/src/common/crypto.js
Normal 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)
|
||||
}
|
||||
}
|
||||
8
js/pubky/src/common/index.js
Normal file
8
js/pubky/src/common/index.js
Normal 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
|
||||
*/
|
||||
1
js/pubky/src/common/namespaces.js
Normal file
1
js/pubky/src/common/namespaces.js
Normal file
@@ -0,0 +1 @@
|
||||
export const PUBKY_AUTHN = Buffer.from('PUBKY:AUTHN')
|
||||
53
js/pubky/src/common/timestamp.js
Normal file
53
js/pubky/src/common/timestamp.js
Normal 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
32
js/pubky/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user