this repo has no description

feat: add P-256 ECDSA signing via Web Crypto

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+142 -2
src
test
+100 -1
src/pds.js
··· 138 return tid 139 } 140 141 export class PersonalDataServer { 142 constructor(state, env) { 143 this.state = state ··· 200 `).toArray() 201 return Response.json({ tables: tables.map(t => t.name) }) 202 } 203 return new Response('pds running', { status: 200 }) 204 } 205 } ··· 220 } 221 222 // Export utilities for testing 223 - export { cborEncode, createCid, cidToString, base32Encode, createTid }
··· 138 return tid 139 } 140 141 + // === P-256 SIGNING === 142 + // Web Crypto ECDSA with P-256 curve 143 + 144 + async function importPrivateKey(privateKeyBytes) { 145 + // PKCS#8 wrapper for raw P-256 private key 146 + const pkcs8Prefix = new Uint8Array([ 147 + 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 148 + 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 149 + 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20 150 + ]) 151 + 152 + const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32) 153 + pkcs8.set(pkcs8Prefix) 154 + pkcs8.set(privateKeyBytes, pkcs8Prefix.length) 155 + 156 + return crypto.subtle.importKey( 157 + 'pkcs8', 158 + pkcs8, 159 + { name: 'ECDSA', namedCurve: 'P-256' }, 160 + false, 161 + ['sign'] 162 + ) 163 + } 164 + 165 + async function sign(privateKey, data) { 166 + const signature = await crypto.subtle.sign( 167 + { name: 'ECDSA', hash: 'SHA-256' }, 168 + privateKey, 169 + data 170 + ) 171 + return new Uint8Array(signature) 172 + } 173 + 174 + async function generateKeyPair() { 175 + const keyPair = await crypto.subtle.generateKey( 176 + { name: 'ECDSA', namedCurve: 'P-256' }, 177 + true, 178 + ['sign', 'verify'] 179 + ) 180 + 181 + // Export private key as raw bytes 182 + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) 183 + const privateBytes = base64UrlDecode(privateJwk.d) 184 + 185 + // Export public key as compressed point 186 + const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey) 187 + const publicBytes = new Uint8Array(publicRaw) 188 + const compressed = compressPublicKey(publicBytes) 189 + 190 + return { privateKey: privateBytes, publicKey: compressed } 191 + } 192 + 193 + function compressPublicKey(uncompressed) { 194 + // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 195 + // compressed is 33 bytes: prefix(02 or 03) + x(32) 196 + const x = uncompressed.slice(1, 33) 197 + const y = uncompressed.slice(33, 65) 198 + const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03 199 + const compressed = new Uint8Array(33) 200 + compressed[0] = prefix 201 + compressed.set(x, 1) 202 + return compressed 203 + } 204 + 205 + function base64UrlDecode(str) { 206 + const base64 = str.replace(/-/g, '+').replace(/_/g, '/') 207 + const binary = atob(base64) 208 + const bytes = new Uint8Array(binary.length) 209 + for (let i = 0; i < binary.length; i++) { 210 + bytes[i] = binary.charCodeAt(i) 211 + } 212 + return bytes 213 + } 214 + 215 + function bytesToHex(bytes) { 216 + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') 217 + } 218 + 219 + function hexToBytes(hex) { 220 + const bytes = new Uint8Array(hex.length / 2) 221 + for (let i = 0; i < hex.length; i += 2) { 222 + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) 223 + } 224 + return bytes 225 + } 226 + 227 export class PersonalDataServer { 228 constructor(state, env) { 229 this.state = state ··· 286 `).toArray() 287 return Response.json({ tables: tables.map(t => t.name) }) 288 } 289 + if (url.pathname === '/test/sign') { 290 + const kp = await generateKeyPair() 291 + const data = new TextEncoder().encode('test message') 292 + const key = await importPrivateKey(kp.privateKey) 293 + const sig = await sign(key, data) 294 + return Response.json({ 295 + publicKey: bytesToHex(kp.publicKey), 296 + signature: bytesToHex(sig) 297 + }) 298 + } 299 return new Response('pds running', { status: 200 }) 300 } 301 } ··· 316 } 317 318 // Export utilities for testing 319 + export { 320 + cborEncode, createCid, cidToString, base32Encode, createTid, 321 + generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes 322 + }
+42 -1
test/pds.test.js
··· 1 import { test, describe } from 'node:test' 2 import assert from 'node:assert' 3 - import { cborEncode, createCid, cidToString, base32Encode, createTid } from '../src/pds.js' 4 5 describe('CBOR Encoding', () => { 6 test('encodes simple map', () => { ··· 142 assert.strictEqual(tids.size, 100) 143 }) 144 })
··· 1 import { test, describe } from 'node:test' 2 import assert from 'node:assert' 3 + import { 4 + cborEncode, createCid, cidToString, base32Encode, createTid, 5 + generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes 6 + } from '../src/pds.js' 7 8 describe('CBOR Encoding', () => { 9 test('encodes simple map', () => { ··· 145 assert.strictEqual(tids.size, 100) 146 }) 147 }) 148 + 149 + describe('P-256 Signing', () => { 150 + test('generates key pair with correct sizes', async () => { 151 + const kp = await generateKeyPair() 152 + 153 + assert.strictEqual(kp.privateKey.length, 32) 154 + assert.strictEqual(kp.publicKey.length, 33) // compressed 155 + assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03) 156 + }) 157 + 158 + test('can sign data with generated key', async () => { 159 + const kp = await generateKeyPair() 160 + const key = await importPrivateKey(kp.privateKey) 161 + const data = new TextEncoder().encode('test message') 162 + const sig = await sign(key, data) 163 + 164 + assert.strictEqual(sig.length, 64) // r (32) + s (32) 165 + }) 166 + 167 + test('different messages produce different signatures', async () => { 168 + const kp = await generateKeyPair() 169 + const key = await importPrivateKey(kp.privateKey) 170 + 171 + const sig1 = await sign(key, new TextEncoder().encode('message 1')) 172 + const sig2 = await sign(key, new TextEncoder().encode('message 2')) 173 + 174 + assert.notDeepStrictEqual(sig1, sig2) 175 + }) 176 + 177 + test('bytesToHex and hexToBytes roundtrip', () => { 178 + const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]) 179 + const hex = bytesToHex(original) 180 + const back = hexToBytes(hex) 181 + 182 + assert.strictEqual(hex, '000ff0ffabcd') 183 + assert.deepStrictEqual(back, original) 184 + }) 185 + })