commit 73972ce951669b434108257340b6f6e52906b9ee Author: Andre Staltz Date: Mon Jun 12 14:22:15 2023 +0300 init diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..223bd3f --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + strategy: + matrix: + node-version: [16.x, 18.x, 20.x] + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + timeout-minutes: 2 + + steps: + - name: Checkout the repo + uses: actions/checkout@v2 + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44bcc03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.nyc_output +coverage +pnpm-lock.yaml \ No newline at end of file diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..1d2127c --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,2 @@ +semi: false +singleQuote: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9b34bc1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2023 Andre 'Staltz' Medeiros + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ef26ce --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# ppppp-keypair + +Like `ssb-keys`, but for PPPPP. + +API: + +- `generate(curve?, seed?) => Keypair {curve, public, private}` +- `sign(keypair, msg, hmacKey?) => sig` +- `verify(keypair, msg, sig, hmacKey?) => boolean` +- `create(filepath, cb)` +- `load(filepath, cb)` +- `loadOrCreate(filepath, cb)` +- `createSync(filepath)` +- `loadSync(filepath)` +- `loadOrCreateSync(filepath)` diff --git a/lib/curves/ed25519.js b/lib/curves/ed25519.js new file mode 100644 index 0000000..d62bde2 --- /dev/null +++ b/lib/curves/ed25519.js @@ -0,0 +1,101 @@ +// @ts-ignore +const sodium = require('sodium-universal') +const base58 = require('bs58') + +/** + * @typedef {import('.').Keypair} Keypair + * + * @typedef {import('.').KeypairPublicSlice} KeypairPublicSlice + * + * @typedef {import('.').KeypairPrivateSlice} KeypairPrivateSlice + */ + +const SEEDBYTES = sodium.crypto_sign_SEEDBYTES +const PUBLICKEYBYTES = sodium.crypto_sign_PUBLICKEYBYTES +const SECRETKEYBYTES = sodium.crypto_sign_SECRETKEYBYTES + +const ed25519 = { + /** + * @param {(string | Buffer)=} seed + * @returns {Keypair} + */ + generate(seed) { + let seedBuf + if (seed) { + if (Buffer.isBuffer(seed)) { + // prettier-ignore + if (seed.length !== SEEDBYTES) throw new Error(`seed must be ${SEEDBYTES} bytes`) + seedBuf = seed + } else if (typeof seed === 'string') { + seedBuf = Buffer.alloc(SEEDBYTES) + const slice = seed.substring(0, SEEDBYTES) + Buffer.from(slice, 'utf-8').copy(seedBuf) + } + } + + const publicKeyBuf = Buffer.alloc(PUBLICKEYBYTES) + const secretKeyBuf = Buffer.alloc(SECRETKEYBYTES) + if (seedBuf) { + sodium.crypto_sign_seed_keypair(publicKeyBuf, secretKeyBuf, seedBuf) + } else { + sodium.crypto_sign_keypair(publicKeyBuf, secretKeyBuf) + } + + return { + curve: 'ed25519', + public: base58.encode(publicKeyBuf), + private: base58.encode(secretKeyBuf), + _public: publicKeyBuf, + _private: secretKeyBuf, + } + }, + + /** + * @param {Keypair} keypair + * @param {{indented?: boolean}=} opts + */ + toJSON(keypair, opts) { + const stringifiable = { + curve: keypair.curve, + public: keypair.public, + private: keypair.private, + } + if (opts?.indented) { + return JSON.stringify(stringifiable, null, 2) + } else { + return JSON.stringify(stringifiable) + } + }, + + /** + * @param {KeypairPrivateSlice} keypair + * @param {Buffer} message + * @returns {string} + */ + sign(keypair, message) { + if (!keypair._private && !keypair.private) { + throw new Error(`invalid ed25519 keypair with missing private key`) + } + keypair._private ??= Buffer.from(base58.decode(keypair.private)) + const sig = Buffer.alloc(sodium.crypto_sign_BYTES) + sodium.crypto_sign_detached(sig, message, keypair._private) + return base58.encode(sig) + }, + + /** + * @param {KeypairPublicSlice} keypair + * @param {string} sig + * @param {Buffer} message + * @returns {boolean} + */ + verify(keypair, sig, message) { + if (!keypair._public && !keypair.public) { + throw new Error(`invalid ed25519 keypair with missing public key`) + } + keypair._public ??= Buffer.from(base58.decode(keypair.public)) + const sigBuf = Buffer.from(base58.decode(sig)) + return sodium.crypto_sign_verify_detached(sigBuf, message, keypair._public) + }, +} + +module.exports = ed25519 diff --git a/lib/curves/index.js b/lib/curves/index.js new file mode 100644 index 0000000..8732b0a --- /dev/null +++ b/lib/curves/index.js @@ -0,0 +1,60 @@ +const ed25519 = require('./ed25519') + +const curves = { + ed25519, +} + +/** + * @typedef {keyof typeof curves} CurveName + * + * @typedef {{ + * curve: CurveName, + * public: string, + * private: string, + * _public?: Buffer, + * _private?: Buffer, + * }} Keypair + * + * @typedef {(Pick & {_public: never}) | + * (Pick & {public: never}) + * } KeypairPublicSlice + * + * @typedef {(Pick & {_private: never}) | + * (Pick & {private: never}) + * } KeypairPrivateSlice + * + * @typedef {{ + * generate: (seed?: Buffer | string) => Keypair, + * toJSON: (keypair: Keypair, opts?: {indented?: boolean}) => string, + * sign: (keypair: KeypairPrivateSlice, message: Buffer) => Buffer, + * verify: (keypair: KeypairPublicSlice, message: Buffer, sig: Buffer) => boolean, + * }} Curve + */ + +/** + * @param {CurveName} curveName + */ +function getCurve(curveName) { + if (!curves[curveName]) { + // prettier-ignore + throw new Error(`Unknown curve "${curveName}" out of available "${Object.keys(curves).join(',')}"`) + } + return curves[curveName] +} + +/** + * This function generates a keypair for the given curve. The seed is optional. + * + * @param {CurveName=} curveName + * @param {(Buffer | string)=} seed + * @returns {Keypair} + */ +function generate(curveName, seed) { + const curve = getCurve(curveName ?? 'ed25519') + return curve.generate(seed) +} + +module.exports = { + getCurve, + generate, +} diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..3bf72bf --- /dev/null +++ b/lib/index.js @@ -0,0 +1,97 @@ +// @ts-ignore +const sodium = require('sodium-universal') +const base58 = require('bs58') +const { getCurve, generate } = require('./curves') +const StorageClass = + typeof window !== 'undefined' + ? require('./storage/browser') + : require('./storage/node') + +/** + * @typedef {import('./curves').Keypair} Keypair + * @typedef {import('./curves').KeypairPublicSlice} KeypairPublicSlice + * @typedef {import('./curves').KeypairPrivateSlice} KeypairPrivateSlice + * @typedef {import('./curves').CurveName} CurveName + */ + +/** + * @param {any} x + * @returns {x is string} + */ +function isString(x) { + return typeof x === 'string' +} + +/** + * @param {any} x + * @returns {x is Buffer} + */ +function isBuffer(x) { + return Buffer.isBuffer(x) +} + +/** + * @param {Buffer} input + * @param {string | Buffer} key + * @returns {Buffer} + */ +function hmac(input, key) { + if (isString(key)) key = Buffer.from(base58.decode(key)) + const output = Buffer.alloc(sodium.crypto_auth_BYTES) + sodium.crypto_auth(output, input, key) + return output +} + +/** + * Takes a keypair object (where `.public` is allowed to be undefined), a + * message as a buffer (and an optional hmacKey) and returns a signature of the + * given message. The signature is string encoded in base58. + * + * @param {KeypairPrivateSlice} keypair + * @param {Buffer} msg + * @param {Buffer | string | undefined} hmacKey + * @returns {string} + */ +function sign(keypair, msg, hmacKey) { + if (!isBuffer(msg)) throw new Error('Signable message should be buffer') + const curve = getCurve(keypair.curve) + + if (hmacKey) msg = hmac(msg, hmacKey) + + return curve.sign(keypair, msg) +} + +/** + * Takes a keypair object (where `private` is allowed to be undefined), a + * message buffer and its signature string (and an optional hmacKey), and + * returns true if the signature is valid for the message, false otherwise. + * + * @param {KeypairPublicSlice} keypair + * @param {Buffer} msg + * @param {string} sig + * @param {Buffer | string | undefined} hmacKey + * @returns {boolean} + */ +function verify(keypair, msg, sig, hmacKey) { + if (!isString(sig)) throw new Error('sig should be string') + if (!isBuffer(msg)) throw new Error('Signed message should be buffer') + const curve = getCurve(keypair.curve) + + if (hmacKey) msg = hmac(msg, hmacKey) + + return curve.verify(keypair, sig, msg) +} + +const storage = new StorageClass() + +module.exports = { + generate, + sign, + verify, + create: storage.create.bind(storage), + load: storage.load.bind(storage), + createSync: storage.createSync.bind(storage), + loadSync: storage.loadSync.bind(storage), + loadOrCreate: storage.loadOrCreate.bind(storage), + loadOrCreateSync: storage.loadOrCreateSync.bind(storage), +} diff --git a/lib/storage/browser.js b/lib/storage/browser.js new file mode 100644 index 0000000..1cfa2a1 --- /dev/null +++ b/lib/storage/browser.js @@ -0,0 +1,74 @@ +const { generate, getCurve } = require('../curves') +const Storage = require('./common') + +/** + * @typedef {import('../curves').Keypair} Keypair + * + * @typedef {(...args: [Error] | [null, Keypair]) => void} Callback + */ + +class BrowserStorage extends Storage { + constructor() { + super() + } + + /** + * @param {string} identifier + * @returns {Keypair} + */ + createSync(identifier) { + const keypair = generate() + const curve = getCurve(keypair.curve) + const jsonStr = curve.toJSON(keypair, { indented: false }) + localStorage.setItem(identifier, jsonStr) + return keypair + } + + /** + * @param {string} identifier + * @returns {Keypair} + */ + loadSync(identifier) { + const item = localStorage.getItem(identifier) + if (!item) { + throw new Error(`No keypair found at localStorage "${identifier}"`) + } + try { + return JSON.parse(item) + } catch { + throw new Error(`Malformed keypair JSON in localStorage ${identifier}`) + } + } + + /** + * @param {string} identifier + * @param {Callback} cb + */ + create(identifier, cb) { + let keypair + try { + keypair = this.createSync(identifier) + } catch (err) { + cb(/** @type {Error} */ (err)) + return + } + cb(null, keypair) + } + + /** + * @param {string} identifier + * @param {Callback} cb + */ + load(identifier, cb) { + let keypair + try { + keypair = this.loadSync(identifier) + } catch (err) { + cb(/** @type {Error} */ (err)) + return + } + cb(null, keypair) + } +} + +module.exports = BrowserStorage diff --git a/lib/storage/common.js b/lib/storage/common.js new file mode 100644 index 0000000..5ee1ea2 --- /dev/null +++ b/lib/storage/common.js @@ -0,0 +1,66 @@ +/** + * @typedef {import('../curves').Keypair} Keypair + * + * @typedef {import('../curves').CurveName} CurveName + * + * @typedef {(...args: [Error] | [null, Keypair]) => void} Callback + */ + +class Storage { + /** + * @param {string} identifier + * @param {Callback} cb + */ + load(identifier, cb) { + throw new Error('load() missing an implementation') + } + + /** + * @param {string} identifier + * @param {Callback} cb + */ + create(identifier, cb) { + throw new Error('create() missing an implementation') + } + + /** + * @param {string} identifier + * @returns {Keypair} + */ + loadSync(identifier) { + throw new Error('loadSync() missing an implementation') + } + + /** + * @param {string} identifier + * @returns {Keypair} + */ + createSync(identifier) { + throw new Error('createSync() missing an implementation') + } + + /** + * @param {string} identifier + * @param {Callback} cb + */ + loadOrCreate(identifier, cb) { + this.load(identifier, (err, keypair) => { + if (!err) return cb(null, keypair) + else this.create(identifier, cb) + }) + } + + /** + * @param {string} identifier + * @returns {Keypair} + */ + loadOrCreateSync(identifier) { + try { + return this.loadSync(identifier) + } catch { + return this.createSync(identifier) + } + } +} + +module.exports = Storage diff --git a/lib/storage/node.js b/lib/storage/node.js new file mode 100644 index 0000000..e954830 --- /dev/null +++ b/lib/storage/node.js @@ -0,0 +1,126 @@ +const fs = require('node:fs') +const path = require('node:path') +const { mkdirp } = require('mkdirp') +const { generate, getCurve } = require('../curves') +const Storage = require('./common') + +/** + * @typedef {import('../curves').Keypair} Keypair + * + * @typedef {(...args: [Error] | [null, Keypair]) => void} Callback + */ + +class NodeStorage extends Storage { + constructor() { + super() + } + + /** @type {BufferEncoding} */ + #fileEncoding = 'ascii' + + /** @type {fs.WriteFileOptions} */ + #fileWriteOpts = { mode: 0x100, flag: 'wx', encoding: this.#fileEncoding } + + /** + * @param {Keypair} keypair + * @returns {string} + */ + #toFileContents(keypair) { + const curve = getCurve(keypair.curve) + const jsonStr = curve.toJSON(keypair, { indented: true }) + + return `# WARNING: Never show this to anyone. +# WARNING: Never edit it or use it on multiple devices at once. +# +# This is your SECRET, it gives you magical powers. With your secret you can +# sign your messages so that your friends can verify that the messages came +# from you. If anyone learns your secret, they can use it to impersonate you. +# +# If you use this secret on more than one device you will create a fork and +# your friends will stop replicating your content. +# +${jsonStr} +# +# The only part of this file that's safe to share is your public name: +# +# ${keypair.public}` + } + + /** + * @param {string} contents + * @return {Keypair} + */ + #fromFileContents(contents) { + const json = contents + .replace(/\s*#[^\n]*/g, '') + .split('\n') + .filter((x) => !!x) + .join('') + + try { + const keypair = JSON.parse(json) + return keypair + } catch { + throw new Error(`Malformed keypair JSON in file contents`) + } + } + + /** + * @param {string} filename + * @returns {Keypair} + */ + createSync(filename) { + const keypair = generate() + const fileContents = this.#toFileContents(keypair) + mkdirp.sync(path.dirname(filename)) + fs.writeFileSync(filename, fileContents, this.#fileWriteOpts) + return keypair + } + + /** + * @param {string} filename + * @returns {Keypair} + */ + loadSync(filename) { + const fileContents = fs.readFileSync(filename, this.#fileEncoding) + const keypair = this.#fromFileContents(fileContents) + return keypair + } + + /** + * @param {string} filename + * @param {Callback} cb + */ + create(filename, cb) { + const keypair = generate() + const fileContents = this.#toFileContents(keypair) + mkdirp(path.dirname(filename)).then(() => { + fs.writeFile(filename, fileContents, this.#fileWriteOpts, (err) => { + if (err) cb(err) + else cb(null, keypair) + }) + }, cb) + } + + /** + * @param {string} filename + * @param {Callback} cb + */ + load(filename, cb) { + fs.readFile(filename, this.#fileEncoding, (err, fileContents) => { + if (err) return cb(err) + + /** @type {Keypair} */ + let keypair + try { + keypair = this.#fromFileContents(fileContents) + } catch (err) { + cb(/** @type {Error} */ (err)) + return + } + cb(null, keypair) + }) + } +} + +module.exports = NodeStorage diff --git a/package.json b/package.json new file mode 100644 index 0000000..b980d77 --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "ppppp-keypair", + "description": "Keyfile operations for PPPPP", + "version": "0.0.1", + "homepage": "https://github.com/staltz/ppppp-keypair", + "repository": { + "type": "git", + "url": "git://github.com/staltz/ppppp-keypair.git" + }, + "author": "Andre 'Staltz' Medeiros ", + "license": "MIT", + "main": "lib/index.js", + "files": [ + "lib/**/*" + ], + "engines": { + "node": ">=16" + }, + "exports": { + ".": { + "require": "./lib/index.js" + } + }, + "dependencies": { + "bs58": "^5.0.0", + "sodium-universal": "^4.0.0", + "mkdirp": "~3.0.1" + }, + "devDependencies": { + "@types/node": "^20.2.5", + "c8": "^7.11.0", + "husky": "^4.3.0", + "prettier": "^2.6.2", + "pretty-quick": "^3.1.3", + "typescript": "^5.0.2" + }, + "scripts": { + "build": "tsc", + "test": "node --test", + "format-code": "prettier --write \"*.js\" \"(test|lib)/*.js\"", + "format-code-staged": "pretty-quick --staged --pattern \"*.js\" --pattern \"(test|lib)/*.js\"", + "coverage": "c8 --reporter=lcov npm run test" + }, + "husky": { + "hooks": { + "pre-commit": "pretty-quick --staged" + } + } +} diff --git a/test/fs.test.js b/test/fs.test.js new file mode 100644 index 0000000..44f9da4 --- /dev/null +++ b/test/fs.test.js @@ -0,0 +1,130 @@ +const test = require('node:test') +const assert = require('node:assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const Keypair = require('../lib/index') + +const keyPath = path.join(os.tmpdir(), `ppppp-keypair-${Date.now()}`) + +test('loadSync()', (t) => { + const keypair = Keypair.generate('ed25519') + fs.writeFileSync(keyPath, JSON.stringify(keypair)) + + const keypair2 = Keypair.loadSync(keyPath) + assert.ok(keypair2.public) + assert.equal(keypair2.public, keypair.public) + fs.unlinkSync(keyPath) +}) + +test('load()', (t, done) => { + const keypair = Keypair.generate('ed25519') + fs.writeFileSync(keyPath, JSON.stringify(keypair)) + + Keypair.load(keyPath, (err, keypair2) => { + assert.ifError(err) + assert.ok(keypair2.public) + assert.equal(keypair2.public, keypair.public) + fs.unlinkSync(keyPath) + done() + }) +}) + +test('create() then load()', (t, done) => { + Keypair.create(keyPath, (err, k1) => { + assert.ifError(err) + Keypair.load(keyPath, (err, k2) => { + assert.ifError(err) + assert.equal(k1.private, k2.private) + assert.equal(k1.public, k2.public) + fs.unlinkSync(keyPath) + done() + }) + }) +}) + +test('createSync() then loadSync()', (t) => { + const k1 = Keypair.createSync(keyPath) + const k2 = Keypair.loadSync(keyPath) + assert.equal(k1.private, k2.private) + assert.equal(k1.public, k2.public) + fs.unlinkSync(keyPath) +}) + +test('create()/createSync() avoid overwriting existing keys', (t, done) => { + fs.writeFileSync(keyPath, 'this file intentionally left blank', 'utf8') + assert.throws(() => { + Keypair.createSync(keyPath) + }) + Keypair.create(keyPath, (err) => { + assert.ok(err) + done() + }) +}) + +test('loadOrCreate() can load', (t, done) => { + const keyPath = path.join(os.tmpdir(), `ssb-keys-1-${Date.now()}`) + const keypair = Keypair.generate('ed25519') + fs.writeFileSync(keyPath, JSON.stringify(keypair)) + + Keypair.loadOrCreate(keyPath, (err, keypair2) => { + assert.ifError(err) + assert.ok(keypair2.public) + assert.equal(keypair2.public, keypair.public) + fs.unlinkSync(keyPath) + done() + }) +}) + +test('loadOrCreate() can create', (t, done) => { + const keyPath = path.join(os.tmpdir(), `ssb-keys-2-${Date.now()}`) + assert.equal(fs.existsSync(keyPath), false) + + Keypair.loadOrCreate(keyPath, (err, keypair) => { + assert.ifError(err) + assert.ok(keypair.public.length > 20, 'keys.public is a long string') + assert.ok(keypair.private.length > 20, 'keys.private is a long string') + assert.equal(typeof keypair.curve, 'string', 'keys.curve is a string') + fs.unlinkSync(keyPath) + done() + }) +}) + +test('loadOrCreateSync() can load', (t) => { + const keyPath = path.join(os.tmpdir(), `ssb-keys-3-${Date.now()}`) + const keypair = Keypair.generate('ed25519') + fs.writeFileSync(keyPath, JSON.stringify(keypair)) + + const keypair2 = Keypair.loadOrCreateSync(keyPath) + assert.ok(keypair2.public) + assert.equal(keypair2.public, keypair.public) + fs.unlinkSync(keyPath) +}) + +test('loadOrCreateSync() can create', (t) => { + const keyPath = path.join(os.tmpdir(), `ssb-keys-4-${Date.now()}`) + assert.equal(fs.existsSync(keyPath), false) + + const keypair = Keypair.loadOrCreateSync(keyPath) + assert.ok(keypair.public.length > 20, 'keys.public is a long string') + assert.ok(keypair.private.length > 20, 'keys.private is a long string') + assert.ok(keypair.curve, 'keys.curve is a string') + fs.unlinkSync(keyPath) +}) + +test('loadOrCreate() doesnt create dir for fully-specified path', (t, done) => { + const keyPath = path.join(os.tmpdir(), `ssb-keys-5-${Date.now()}`) + assert.equal(fs.existsSync(keyPath), false) + + Keypair.loadOrCreate(keyPath, (err) => { + assert.ifError(err) + assert.ok(fs.lstatSync(keyPath).isFile()) + + Keypair.loadOrCreate(keyPath, (err, keypair) => { + assert.ifError(err) + assert.ok(keypair.public.length > 20) + fs.unlinkSync(keyPath) + done() + }) + }) +}) diff --git a/test/generate.test.js b/test/generate.test.js new file mode 100644 index 0000000..88e442a --- /dev/null +++ b/test/generate.test.js @@ -0,0 +1,37 @@ +const test = require('node:test') +const assert = require('node:assert') +const Keypair = require('../lib/index') + +test('generate() default', (t) => { + const keypair = Keypair.generate() + assert.equal(keypair.curve, 'ed25519') + assert.equal(typeof keypair.public, 'string') + assert.equal(typeof keypair.private, 'string') + assert.equal(Buffer.isBuffer(keypair._public), true) + assert.equal(Buffer.isBuffer(keypair._private), true) + assert.deepEqual(Object.keys(keypair), [ + 'curve', + 'public', + 'private', + '_public', + '_private', + ]) +}) + +test('generate() with ed25519 curve', (t) => { + const keypair = Keypair.generate('ed25519') + assert.equal(keypair.curve, 'ed25519') + assert.equal(typeof keypair.public, 'string') + assert.equal(typeof keypair.private, 'string') +}) + +test('generate() with unknown curve', (t) => { + assert.throws(() => { + Keypair.generate('foobar') + }, /Unknown curve "foobar"/) +}) + +test('generate() with seed', (t) => { + const keypair = Keypair.generate('ed25519', 'alice') + assert.equal(keypair.public, '4mjQ5aJu378cEu6TksRG3uXAiKFiwGjYQtWAjfVjDAJW') +}) \ No newline at end of file diff --git a/test/signing.test.js b/test/signing.test.js new file mode 100644 index 0000000..43f1664 --- /dev/null +++ b/test/signing.test.js @@ -0,0 +1,33 @@ +const test = require('node:test') +const assert = require('node:assert') +const Keypair = require('../lib/index') +const crypto = require('crypto') + +test('sign()/verify() does not work on strings', (t) => { + const str = 'ppppp' + const keypair = Keypair.generate() + assert.throws(() => { + Keypair.sign(keypair, str) + }) +}) + +test('sign()/verify() a buffer without hmac key', (t) => { + const buf = Buffer.from('ppppp') + const keypair = Keypair.generate() + const sig = Keypair.sign(keypair, buf) + assert.ok(sig) + const { public, curve } = keypair + assert.ok(Keypair.verify({ public, curve }, buf, sig)) +}) + +test('sign()/verify a buffer with hmac key', (t) => { + const str = Buffer.from('ppppp') + const keypair = Keypair.generate() + const hmac_key = crypto.randomBytes(32) + const hmac_key2 = crypto.randomBytes(32) + + const sig = Keypair.sign(keypair, str, hmac_key) + assert.ok(sig) + assert.equal(Keypair.verify(keypair, str, sig, hmac_key), true) + assert.equal(Keypair.verify(keypair, str, sig, hmac_key2), false) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f6eb639 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "include": ["lib/**/*.js"], + "exclude": ["coverage/", "node_modules/", "test/"], + "compilerOptions": { + "checkJs": true, + "noEmit": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": ["es2021", "dom"], + "module": "node16", + "skipLibCheck": true, + "strict": true, + "target": "es2021" + } +} \ No newline at end of file