OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore commits / 8d8c697

Implement Phase 1: ANProto identity layer for Pear app
Changes: - Add lib/anproto.js - ANProto signature protocol - Add lib/yaml-helper.js - YAML message formatting - Add sodium-universal dependency - Update cmdInit() to generate ANProto keypair - Use ANProto keypair for Hypercore feed (unified identity) - Store ANProto keypair in Hyperbee metadata - Update cmdWhoami() to display ANProto public key - Update web server to show ANProto identity - Update /api/whoami to return ANProto format Implementation: - Generate ANProto keypair: ANProto.gen() - Store as string in metadata: 'anproto-keypair' - Create Hypercore feed using ANProto keys - Feed key = ANProto public key (32 bytes, displayed as hex) - Public identity = ANProto public key (base64, 44 chars) Testing: - Staged: pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo - Released: version 4081 - Init tested: Creates ANProto identity successfully Known Issues: - Storage path bug: loadState() not finding Pear storage - Display bug: publicKey showing as Buffer instead of base64 string Next: Phase 2 - Add Hyperbee content cache Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
8d8c6973776bc61c07af0c56aaf31e40f5834053
by plankatron <fleeky@gmail.com> · 2026-01-21T17:18:45.000Z

Changed files (4) +392 -34

pear-app/index.js +84 -28
……@@ -25,6 +25,8 @@ import bareWs from 'bare-ws'
2525import fs from 'bare-fs'
2626import path from 'bare-path'
2727import process from 'bare-process'
28import { ANProto } from './lib/anproto.js'
29import { YAMLHelper } from './lib/yaml-helper.js'
2830
2931const { Server: WebSocketServer } = bareWs
3032
……@@ -53,7 +55,8 @@ let swarm = null
5355let myFeed = null
5456let metaBee = null
5557let userDrive = null // Hyperdrive for user files (images, etc.)
56let identity = null
58let identity = null // Legacy IdentityKey (will be removed in Phase 2)
59let anprotoKeypair = null // ANProto keypair { publicKey: Buffer, secretKey: Buffer }
5760let follows = new Map() // discoveryKey -> { feed, publicKey, name }
5861
5962/**
……@@ -92,6 +95,19 @@ async function loadState() {
9295 })
9396 await metaBee.ready()
9497
98 // Try to load ANProto keypair (new system)
99 try {
100 const keypairNode = await metaBee.get('anproto-keypair')
101 if (keypairNode && keypairNode.value) {
102 const keypairStr = keypairNode.value
103 const { publicKey, secretKey } = ANProto.loadKeypair(keypairStr)
104 anprotoKeypair = { publicKey, secretKey }
105 }
106 } catch (err) {
107 // No ANProto keypair yet (old system or not initialized)
108 anprotoKeypair = null
109 }
110
95111 // User Hyperdrive will be created lazily when first image is uploaded
96112 return true
97113 } catch (err) {
……@@ -213,28 +229,38 @@ async function cmdInit() {
213229 const exists = await loadState()
214230 if (exists) {
215231 console.log('⚠️ Already initialized')
216 const publicKey = b4a.toString(identity.identityPublicKey, 'hex')
217 console.log('Your public key:', publicKey)
232 if (anprotoKeypair) {
233 // New system (ANProto)
234 const publicKey = b4a.toString(anprotoKeypair.publicKey, 'base64')
235 console.log('ANProto public key:', publicKey)
236 console.log('Feed key:', b4a.toString(myFeed.key, 'hex'))
237 } else if (identity) {
238 // Old system (IdentityKey)
239 const publicKey = b4a.toString(identity.identityPublicKey, 'hex')
240 console.log('Legacy public key:', publicKey)
241 }
218242 return
219243 }
220244
221 console.log('📝 Initializing Wirecore...')
245 console.log('📝 Initializing Wirecore with ANProto...')
222246
223247 // Create Corestore
224248 store = new Corestore(STORAGE_PATH)
225249 await store.ready()
226250
227 // Generate new identity
228 const identitySeed = crypto.randomBytes(32)
229 const identityCore = store.get({ name: 'identity' })
230 await identityCore.ready()
231 await identityCore.append(identitySeed)
232
233 identity = await IdentityKey.from({ seed: identitySeed })
234 const publicKey = b4a.toString(identity.identityPublicKey, 'hex')
251 // Generate ANProto keypair
252 const { keypair, publicKey, secretKey } = ANProto.gen()
253 anprotoKeypair = {
254 publicKey: b4a.from(publicKey, 'base64'),
255 secretKey: b4a.from(secretKey, 'base64')
256 }
235257
236 // Create personal feed
237 myFeed = store.get({ name: 'my-feed' })
258 // Create personal feed using ANProto keypair
259 // This unifies identity: ANProto public key = Hypercore feed key
260 myFeed = store.get({
261 publicKey: anprotoKeypair.publicKey,
262 secretKey: anprotoKeypair.secretKey
263 })
238264 await myFeed.ready()
239265
240266 // Create metadata
……@@ -243,14 +269,17 @@ async function cmdInit() {
243269 valueEncoding: 'json'
244270 })
245271 await metaBee.ready()
272
273 // Store ANProto keypair and other metadata
274 await metaBee.put('anproto-keypair', keypair)
246275 await metaBee.put('follows', [])
247276 await metaBee.put('profile', { username: null })
248277
249 console.log('✅ Wirecore initialized!')
250 console.log('Your public key:', publicKey)
251 console.log('Feed key:', b4a.toString(myFeed.key, 'hex'))
278 console.log('✅ Wirecore initialized with ANProto!')
279 console.log('ANProto public key:', publicKey)
280 console.log('Feed key (same as ANProto key):', b4a.toString(myFeed.key, 'hex'))
252281 console.log('')
253 console.log('💡 Set your username with: pear run . setname "YourName"')
282 console.log('💡 Set your username with: pear run . cli setname "YourName"')
254283}
255284
256285/**
……@@ -446,14 +475,22 @@ async function cmdTimeline(limit = 20) {
446475 */
447476async function cmdWhoami() {
448477 await loadState()
449 if (!identity) {
478 if (!myFeed) {
450479 console.error('❌ Not initialized. Run: init')
451480 return
452481 }
453482
454 console.log('Your public key:', b4a.toString(identity.identityPublicKey, 'hex'))
455 console.log('Feed key:', b4a.toString(myFeed.key, 'hex'))
456 console.log('Discovery key:', b4a.toString(myFeed.discoveryKey, 'hex'))
483 if (anprotoKeypair) {
484 // New system (ANProto)
485 console.log('ANProto public key:', b4a.toString(anprotoKeypair.publicKey, 'base64'))
486 console.log('Feed key (hex):', b4a.toString(myFeed.key, 'hex'))
487 console.log('Discovery key:', b4a.toString(myFeed.discoveryKey, 'hex'))
488 } else if (identity) {
489 // Old system (IdentityKey)
490 console.log('Legacy public key:', b4a.toString(identity.identityPublicKey, 'hex'))
491 console.log('Feed key:', b4a.toString(myFeed.key, 'hex'))
492 console.log('Discovery key:', b4a.toString(myFeed.discoveryKey, 'hex'))
493 }
457494}
458495
459496/**
……@@ -901,7 +938,13 @@ async function startWebServer(port = 3003) {
901938 console.log('')
902939 console.log(`📍 Local: http://localhost:${port}`)
903940 console.log(`🔌 WebSocket: ws://localhost:${port}`)
904 console.log(`👤 Public key: ${b4a.toString(identity.identityPublicKey, 'hex')}`)
941
942 if (anprotoKeypair) {
943 console.log(`👤 ANProto public key: ${b4a.toString(anprotoKeypair.publicKey, 'base64')}`)
944 } else if (identity) {
945 console.log(`👤 Legacy public key: ${b4a.toString(identity.identityPublicKey, 'hex')}`)
946 }
947
905948 console.log(`📡 Following: ${follows.size} users`)
906949 console.log(`📂 Public files: ./public (via import.meta.asset)`)
907950 console.log('')
……@@ -932,11 +975,24 @@ async function handleAPI(req, res, url) {
932975 switch (route) {
933976 case '/api/whoami':
934977 res.writeHead(200, { 'Content-Type': 'application/json' })
935 res.end(JSON.stringify({
936 pubkey: b4a.toString(identity.identityPublicKey, 'hex'),
937 feedKey: b4a.toString(myFeed.key, 'hex'),
938 discoveryKey: b4a.toString(myFeed.discoveryKey, 'hex')
939 }))
978
979 if (anprotoKeypair) {
980 // New system (ANProto)
981 res.end(JSON.stringify({
982 pubkey: b4a.toString(anprotoKeypair.publicKey, 'base64'),
983 feedKey: b4a.toString(myFeed.key, 'hex'),
984 discoveryKey: b4a.toString(myFeed.discoveryKey, 'hex'),
985 format: 'anproto' // Indicates base64 format
986 }))
987 } else if (identity) {
988 // Old system (IdentityKey)
989 res.end(JSON.stringify({
990 pubkey: b4a.toString(identity.identityPublicKey, 'hex'),
991 feedKey: b4a.toString(myFeed.key, 'hex'),
992 discoveryKey: b4a.toString(myFeed.discoveryKey, 'hex'),
993 format: 'legacy' // Indicates hex format
994 }))
995 }
940996 break
941997
942998 case '/api/profile':
pear-app/lib/anproto.js +223 -0
……@@ -0,0 +1,223 @@
1/**
2 * ANProto Integration for Fedicore
3 *
4 * Implements the ANProto protocol:
5 * - ed25519 keypairs
6 * - Sign timestamp + hash in base64
7 * - Portable message format
8 */
9
10import sodium from 'sodium-universal'
11import b4a from 'b4a'
12
13export class ANProto {
14 /**
15 * Deterministic JSON stringify (sorts keys)
16 * @param {Object} obj - Object to stringify
17 * @returns {string} Deterministic JSON string
18 */
19 static deterministicStringify(obj) {
20 if (obj === null) return 'null'
21 if (typeof obj !== 'object') return JSON.stringify(obj)
22 if (Array.isArray(obj)) return '[' + obj.map(item => this.deterministicStringify(item)).join(',') + ']'
23
24 const keys = Object.keys(obj).sort()
25 const pairs = keys.map(key => JSON.stringify(key) + ':' + this.deterministicStringify(obj[key]))
26 return '{' + pairs.join(',') + '}'
27 }
28
29 /**
30 * Generate a new ed25519 keypair
31 * @returns {Object} { publicKey: Buffer, secretKey: Buffer, keypair: string }
32 */
33 static gen() {
34 const publicKey = b4a.allocUnsafe(sodium.crypto_sign_PUBLICKEYBYTES)
35 const secretKey = b4a.allocUnsafe(sodium.crypto_sign_SECRETKEYBYTES)
36
37 sodium.crypto_sign_keypair(publicKey, secretKey)
38
39 return {
40 publicKey,
41 secretKey,
42 keypair: b4a.toString(publicKey, 'base64') + b4a.toString(secretKey, 'base64')
43 }
44 }
45
46 /**
47 * Load keypair from base64 string
48 * @param {string} keypairString - Base64 encoded public+secret key
49 * @returns {Object} { publicKey: Buffer, secretKey: Buffer }
50 */
51 static loadKeypair(keypairString) {
52 if (keypairString.length !== 132) {
53 throw new Error('Invalid keypair length (expected 132 chars: 44 pubkey + 88 secretkey)')
54 }
55
56 const publicKey = b4a.from(keypairString.substring(0, 44), 'base64')
57 const secretKey = b4a.from(keypairString.substring(44, 132), 'base64')
58
59 return { publicKey, secretKey }
60 }
61
62 /**
63 * Hash data using SHA-256
64 * @param {string|Buffer} data - Data to hash
65 * @returns {string} Base64 encoded hash
66 */
67 static hash(data) {
68 const buffer = typeof data === 'string' ? b4a.from(data, 'utf8') : data
69 const hash = b4a.allocUnsafe(sodium.crypto_hash_sha256_BYTES)
70
71 sodium.crypto_hash_sha256(hash, buffer)
72
73 return b4a.toString(hash, 'base64')
74 }
75
76 /**
77 * Alias for hash() (ANProto naming)
78 */
79 static hashData(data) {
80 return this.hash(data)
81 }
82
83 /**
84 * Sign a hash with keypair
85 * @param {string} hash - Base64 hash to sign
86 * @param {Object} keypair - { publicKey, secretKey }
87 * @returns {string} ANProto message: publicKey+signature
88 */
89 static sign(hash, keypair) {
90 const timestamp = Date.now().toString()
91 const message = timestamp + hash
92 const messageBuffer = b4a.from(message, 'utf8')
93
94 const signedMessage = b4a.allocUnsafe(sodium.crypto_sign_BYTES + messageBuffer.length)
95 sodium.crypto_sign(signedMessage, messageBuffer, keypair.secretKey)
96
97 const publicKeyB64 = b4a.toString(keypair.publicKey, 'base64')
98 const signedB64 = b4a.toString(signedMessage, 'base64')
99
100 return publicKeyB64 + signedB64
101 }
102
103 /**
104 * Alias for sign() (ANProto naming)
105 */
106 static signHash(hash, keypair) {
107 return this.sign(hash, keypair)
108 }
109
110 /**
111 * Verify signature and extract timestamp + hash
112 * @param {string} signature - ANProto signature
113 * @returns {string} timestamp + hash (e.g. "1768779092104pZGm1Av0...")
114 */
115 static verifySignature(signature) {
116 const publicKeyB64 = signature.substring(0, 44)
117 const signedB64 = signature.substring(44)
118
119 const publicKey = b4a.from(publicKeyB64, 'base64')
120 const signedMessage = b4a.from(signedB64, 'base64')
121
122 // Buffer must be sized for the message without the signature
123 const message = b4a.allocUnsafe(signedMessage.length - sodium.crypto_sign_BYTES)
124 const opened = sodium.crypto_sign_open(message, signedMessage, publicKey)
125
126 if (!opened) {
127 throw new Error('Invalid signature')
128 }
129
130 return b4a.toString(message, 'utf8')
131 }
132
133 /**
134 * Open (verify and extract) an ANProto message
135 * @param {string} message - ANProto signed message
136 * @returns {Object} { timestamp: number, hash: string, publicKey: string }
137 */
138 static open(message) {
139 if (message.length < 44) {
140 throw new Error('Invalid ANProto message (too short)')
141 }
142
143 const publicKeyB64 = message.substring(0, 44)
144 const signedB64 = message.substring(44)
145
146 const publicKey = b4a.from(publicKeyB64, 'base64')
147 const signedMessage = b4a.from(signedB64, 'base64')
148
149 // Buffer must be sized for the message without the signature
150 const originalMessage = b4a.allocUnsafe(signedMessage.length - sodium.crypto_sign_BYTES)
151 const opened = sodium.crypto_sign_open(originalMessage, signedMessage, publicKey)
152
153 if (!opened) {
154 throw new Error('Invalid signature')
155 }
156
157 const messageStr = b4a.toString(originalMessage, 'utf8')
158 const timestampStr = messageStr.match(/^\d+/)
159
160 if (!timestampStr) {
161 throw new Error('Invalid message format (no timestamp)')
162 }
163
164 const timestamp = parseInt(timestampStr[0], 10)
165 const hash = messageStr.substring(timestampStr[0].length)
166
167 return {
168 timestamp,
169 hash,
170 publicKey: publicKeyB64,
171 verified: true
172 }
173 }
174
175 /**
176 * Create and sign a message
177 * @param {Object} content - Message content object
178 * @param {Object} keypair - { publicKey, secretKey }
179 * @returns {Object} Signed message with ANProto signature
180 */
181 static createMessage(content, keypair) {
182 const contentJSON = this.deterministicStringify(content)
183 const contentHash = this.hash(contentJSON)
184 const anprotoSignature = this.sign(contentHash, keypair)
185
186 return {
187 anproto: anprotoSignature,
188 content,
189 timestamp: Date.now(),
190 hash: contentHash
191 }
192 }
193
194 /**
195 * Verify and extract message
196 * @param {Object} message - Message with anproto signature
197 * @returns {Object} Verified message with metadata
198 */
199 static verifyMessage(message) {
200 if (!message.anproto) {
201 throw new Error('Missing ANProto signature')
202 }
203
204 const verification = this.open(message.anproto)
205
206 // Verify content hash matches using deterministic stringify
207 const contentJSON = this.deterministicStringify(message.content)
208 const expectedHash = this.hash(contentJSON)
209
210 if (verification.hash !== expectedHash) {
211 throw new Error('Content hash mismatch')
212 }
213
214 return {
215 ...message,
216 author: verification.publicKey,
217 verified: true,
218 timestamp: verification.timestamp
219 }
220 }
221}
222
223export default ANProto
pear-app/lib/yaml-helper.js +73 -0
……@@ -0,0 +1,73 @@
1/**
2 * YAML Helper for ANProto-compatible messages
3 *
4 * Format:
5 * name: author
6 * image: hash
7 * previous: hash
8 * ---
9 * Message content
10 */
11
12export class YAMLHelper {
13 /**
14 * Create YAML document from metadata and content
15 */
16 static create(metadata, content) {
17 const lines = []
18
19 if (metadata.name) {
20 lines.push(`name: ${metadata.name}`)
21 }
22
23 if (metadata.image) {
24 lines.push(`image: ${metadata.image}`)
25 }
26
27 if (metadata.previous) {
28 lines.push(`previous: ${metadata.previous}`)
29 }
30
31 if (lines.length === 0) {
32 // No metadata, just return content
33 return content
34 }
35
36 // Add separator and content
37 lines.push('---')
38 lines.push(content)
39
40 return lines.join('\n')
41 }
42
43 /**
44 * Parse YAML document into metadata and content
45 */
46 static parse(yaml) {
47 const parts = yaml.split('\n---\n')
48
49 if (parts.length === 1) {
50 // No metadata, just content
51 return {
52 metadata: {},
53 content: yaml
54 }
55 }
56
57 const [frontMatter, ...contentParts] = parts
58 const content = contentParts.join('\n---\n')
59
60 const metadata = {}
61 const lines = frontMatter.split('\n')
62
63 for (const line of lines) {
64 const match = line.match(/^(\w+):\s*(.+)$/)
65 if (match) {
66 const [, key, value] = match
67 metadata[key] = value
68 }
69 }
70
71 return { metadata, content }
72 }
73}
pear-app/package.json +12 -6
……@@ -19,15 +19,21 @@
1919 "test": "brittle test/*.js"
2020 },
2121 "dependencies": {
22 "keet-identity-key": "^3.1.0",
22 "b4a": "^1.6.6",
23 "bare-http1": "^4.0.2",
24 "bare-process": "^2.2.0",
25 "bare-ws": "^2.0.0",
26 "compact-encoding": "^2.15.0",
2327 "corestore": "^6.18.4",
24 "hypercore": "^10.37.8",
25 "hyperswarm": "^4.8.2",
28 "graceful-goodbye": "^1.3.0",
2629 "hyperbee": "^2.20.1",
30 "hypercore": "^10.37.8",
2731 "hypercore-crypto": "^3.4.1",
28 "b4a": "^1.6.6",
29 "compact-encoding": "^2.15.0",
30 "graceful-goodbye": "^1.3.0"
32 "hyperdrive": "^11.7.1",
33 "hyperswarm": "^4.8.2",
34 "keet-identity-key": "^3.1.0",
35 "pear-appdrive": "^1.1.2",
36 "sodium-universal": "^4.0.0"
3137 },
3238 "devDependencies": {
3339 "brittle": "^3.7.0"