/**
* Server Identity Manager for Pear-Git P2P
*
* Manages the server's persistent keypair used for:
* - Deriving P2P topic hashes (topic = hash(serverKey + repoName))
* - Identifying the server in pear:// URLs
* - Signing P2P protocol messages (future)
*
* The server key is stored persistently so that pear:// URLs remain stable.
*/
import fs from 'bare-fs'
import path from 'bare-path'
import crypto from 'hypercore-crypto'
import bip39 from 'bip39-mnemonic'
const { entropyToMnemonic, mnemonicToEntropy, validateMnemonic } = bip39
export class ServerIdentity {
constructor(options = {}) {
this.storagePath = options.storagePath
this.keyFile = path.join(this.storagePath, 'server-identity.json')
this.publicKey = null
this.secretKey = null
this._explicitStorage = options.explicitStorage || false
}
/**
* Initialize server identity
* Loads existing keypair or generates new one
*/
async init() {
// Ensure storage directory exists
try {
await fs.promises.mkdir(this.storagePath, { recursive: true })
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
// Try to load existing identity
try {
const data = await fs.promises.readFile(this.keyFile, 'utf-8')
const identity = JSON.parse(data)
this.publicKey = Buffer.from(identity.publicKey, 'hex')
this.secretKey = Buffer.from(identity.secretKey, 'hex')
// Verify file permissions (should be 0600, not world-readable)
try {
const stat = await fs.promises.stat(this.keyFile)
const mode = stat.mode & 0o777
if (mode & 0o077) {
console.warn(`[Identity] WARNING: ${this.keyFile} has mode ${mode.toString(8)}, fixing to 0600`)
await fs.promises.chmod(this.keyFile, 0o600)
}
} catch {}
console.log(`[Identity] Loaded server key: ${this.getPublicKeyHex().slice(0, 8)}...`)
} catch (err) {
if (err.code !== 'ENOENT') throw err
// Generate new identity
await this._generateIdentity()
}
}
/**
* Get public key as hex string (for URLs)
*/
getPublicKeyHex() {
return this.publicKey.toString('hex')
}
/**
* Get public key as buffer (for crypto operations)
*/
getPublicKeyBuffer() {
return this.publicKey
}
/**
* Derive a topic hash for a repository
* topic = hash(serverPublicKey + repoName)
*
* @param {string} repoName - Repository name
* @returns {Buffer} - 32-byte topic hash
*/
deriveTopic(repoName) {
return crypto.hash(Buffer.concat([
this.publicKey,
Buffer.from(repoName, 'utf-8')
])).subarray(0, 32)
}
/**
* Derive topic from any key (for clients)
* @param {string|Buffer} serverKey - Server public key
* @param {string} repoName - Repository name
* @returns {Buffer} - 32-byte topic hash
*/
static deriveTopicFromKey(serverKey, repoName) {
const keyBuffer = typeof serverKey === 'string'
? Buffer.from(serverKey, 'hex')
: serverKey
return crypto.hash(Buffer.concat([
keyBuffer,
Buffer.from(repoName, 'utf-8')
])).subarray(0, 32)
}
/**
* Sign data with server secret key
* @param {Buffer|string} data - Data to sign
* @returns {Buffer} - Signature
*/
sign(data) {
const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data
return crypto.sign(dataBuffer, this.secretKey)
}
/**
* Verify a signature from this server
* @param {Buffer|string} data - Original data
* @param {Buffer|string} signature - Signature to verify
* @returns {boolean}
*/
verify(data, signature) {
const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data
const sigBuffer = typeof signature === 'string' ? Buffer.from(signature, 'hex') : signature
return crypto.verify(dataBuffer, sigBuffer, this.publicKey)
}
/**
* Export identity as 24-word BIP39 mnemonic
* Encodes the first 32 bytes (seed) of the secret key
* @returns {string} - 24-word mnemonic
*/
toMnemonic () {
if (!this.secretKey) throw new Error('No server identity loaded')
const seed = this.secretKey.subarray(0, 32)
return entropyToMnemonic(seed)
}
/**
* Restore identity from a BIP39 mnemonic
* Validates mnemonic, recovers seed, generates keypair, saves to disk
* @param {string} mnemonic - 24-word BIP39 mnemonic
*/
async initFromMnemonic (mnemonic) {
if (!validateMnemonic(mnemonic)) {
throw new Error('Invalid BIP39 mnemonic')
}
const seed = Buffer.from(mnemonicToEntropy(mnemonic))
const keypair = crypto.keyPair(seed)
this.publicKey = keypair.publicKey
this.secretKey = keypair.secretKey
await fs.promises.mkdir(this.storagePath, { recursive: true })
const identity = {
publicKey: this.publicKey.toString('hex'),
secretKey: this.secretKey.toString('hex'),
createdAt: Date.now(),
restoredFromMnemonic: true
}
await fs.promises.writeFile(
this.keyFile,
JSON.stringify(identity, null, 2),
{ encoding: 'utf-8', mode: 0o600 }
)
console.log('[Identity] Restored from mnemonic: ' + this.getPublicKeyHex().slice(0, 8) + '...')
}
/**
* Verify a signature from any server
* @param {Buffer|string} data - Original data
* @param {Buffer|string} signature - Signature
* @param {Buffer|string} publicKey - Server public key
* @returns {boolean}
*/
static verifySignature(data, signature, publicKey) {
const dataBuffer = typeof data === 'string' ? Buffer.from(data) : data
const sigBuffer = typeof signature === 'string' ? Buffer.from(signature, 'hex') : signature
const keyBuffer = typeof publicKey === 'string' ? Buffer.from(publicKey, 'hex') : publicKey
return crypto.verify(dataBuffer, sigBuffer, keyBuffer)
}
/**
* Export identity info (for display, NOT including secret key)
*/
toJSON() {
return {
publicKey: this.getPublicKeyHex(),
shortKey: this.getPublicKeyHex().slice(0, 8) + '...'
}
}
/**
* Get Keet identity proof status
* @returns {{ hasProof: boolean, identityPublicKey: string|null }}
*/
getProofStatus () {
try {
const data = JSON.parse(fs.readFileSync(this.keyFile, 'utf-8'))
return {
hasProof: !!data.proof,
identityPublicKey: data.identityPublicKey || null
}
} catch {
return { hasProof: false, identityPublicKey: null }
}
}
// === Private Methods ===
async _generateIdentity() {
// Safety: check if an identity already exists at the locator path.
// If the locator points to a DIFFERENT path with a valid identity,
// abort to prevent creating a duplicate identity (Issue #29).
// Skip this check when storage was explicitly provided (--storage flag
// or programmatic option) — the caller intentionally wants a separate instance.
if (!this._explicitStorage) {
const { StorageLocator } = await import('../../storage-locator.js')
const locatorPath = StorageLocator.readRaw()
const thisDir = path.resolve(path.dirname(this.keyFile))
if (locatorPath && path.resolve(locatorPath) !== thisDir) {
try {
const locatorIdentity = path.join(locatorPath, 'server-identity.json')
fs.statSync(locatorIdentity)
throw new Error(
`[Identity] ABORT: Existing identity found at ${locatorPath}\n` +
` Current storage: ${this.storagePath}\n` +
` Use --storage=${locatorPath} to use the existing identity`
)
} catch (err) {
if (err.message.startsWith('[Identity] ABORT')) throw err
// Old locator path has no identity — safe to generate
}
}
}
// Only generate if truly first run
const keypair = crypto.keyPair()
this.publicKey = keypair.publicKey
this.secretKey = keypair.secretKey
// Save to disk
const identity = {
publicKey: this.publicKey.toString('hex'),
secretKey: this.secretKey.toString('hex'),
createdAt: Date.now()
}
await fs.promises.writeFile(this.keyFile, JSON.stringify(identity, null, 2), { encoding: 'utf-8', mode: 0o600 })
console.log(`[Identity] Generated new server key: ${this.getPublicKeyHex().slice(0, 8)}...`)
}
}
export default ServerIdentity