diff --git a/src/pds.js b/src/pds.js index 50830be..ec13ceb 100644 --- a/src/pds.js +++ b/src/pds.js @@ -138,6 +138,92 @@ function createTid() { return tid } +// === P-256 SIGNING === +// Web Crypto ECDSA with P-256 curve + +async function importPrivateKey(privateKeyBytes) { + // PKCS#8 wrapper for raw P-256 private key + const pkcs8Prefix = new Uint8Array([ + 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, + 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, + 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20 + ]) + + const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32) + pkcs8.set(pkcs8Prefix) + pkcs8.set(privateKeyBytes, pkcs8Prefix.length) + + return crypto.subtle.importKey( + 'pkcs8', + pkcs8, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ) +} + +async function sign(privateKey, data) { + const signature = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + privateKey, + data + ) + return new Uint8Array(signature) +} + +async function generateKeyPair() { + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'] + ) + + // Export private key as raw bytes + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + const privateBytes = base64UrlDecode(privateJwk.d) + + // Export public key as compressed point + const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey) + const publicBytes = new Uint8Array(publicRaw) + const compressed = compressPublicKey(publicBytes) + + return { privateKey: privateBytes, publicKey: compressed } +} + +function compressPublicKey(uncompressed) { + // uncompressed is 65 bytes: 0x04 + x(32) + y(32) + // compressed is 33 bytes: prefix(02 or 03) + x(32) + const x = uncompressed.slice(1, 33) + const y = uncompressed.slice(33, 65) + const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03 + const compressed = new Uint8Array(33) + compressed[0] = prefix + compressed.set(x, 1) + return compressed +} + +function base64UrlDecode(str) { + const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +function bytesToHex(bytes) { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') +} + +function hexToBytes(hex) { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes +} + export class PersonalDataServer { constructor(state, env) { this.state = state @@ -200,6 +286,16 @@ export class PersonalDataServer { `).toArray() return Response.json({ tables: tables.map(t => t.name) }) } + if (url.pathname === '/test/sign') { + const kp = await generateKeyPair() + const data = new TextEncoder().encode('test message') + const key = await importPrivateKey(kp.privateKey) + const sig = await sign(key, data) + return Response.json({ + publicKey: bytesToHex(kp.publicKey), + signature: bytesToHex(sig) + }) + } return new Response('pds running', { status: 200 }) } } @@ -220,4 +316,7 @@ export default { } // Export utilities for testing -export { cborEncode, createCid, cidToString, base32Encode, createTid } +export { + cborEncode, createCid, cidToString, base32Encode, createTid, + generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes +} diff --git a/test/pds.test.js b/test/pds.test.js index ea9ae8b..8f59490 100644 --- a/test/pds.test.js +++ b/test/pds.test.js @@ -1,6 +1,9 @@ import { test, describe } from 'node:test' import assert from 'node:assert' -import { cborEncode, createCid, cidToString, base32Encode, createTid } from '../src/pds.js' +import { + cborEncode, createCid, cidToString, base32Encode, createTid, + generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes +} from '../src/pds.js' describe('CBOR Encoding', () => { test('encodes simple map', () => { @@ -142,3 +145,41 @@ describe('TID Generation', () => { assert.strictEqual(tids.size, 100) }) }) + +describe('P-256 Signing', () => { + test('generates key pair with correct sizes', async () => { + const kp = await generateKeyPair() + + assert.strictEqual(kp.privateKey.length, 32) + assert.strictEqual(kp.publicKey.length, 33) // compressed + assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03) + }) + + test('can sign data with generated key', async () => { + const kp = await generateKeyPair() + const key = await importPrivateKey(kp.privateKey) + const data = new TextEncoder().encode('test message') + const sig = await sign(key, data) + + assert.strictEqual(sig.length, 64) // r (32) + s (32) + }) + + test('different messages produce different signatures', async () => { + const kp = await generateKeyPair() + const key = await importPrivateKey(kp.privateKey) + + const sig1 = await sign(key, new TextEncoder().encode('message 1')) + const sig2 = await sign(key, new TextEncoder().encode('message 2')) + + assert.notDeepStrictEqual(sig1, sig2) + }) + + test('bytesToHex and hexToBytes roundtrip', () => { + const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]) + const hex = bytesToHex(original) + const back = hexToBytes(hex) + + assert.strictEqual(hex, '000ff0ffabcd') + assert.deepStrictEqual(back, original) + }) +})