/**
* Permission Attestation
*
* Server-signed proofs of user permissions. Users store these alongside their
* existing identity material (keypair, SSH key, etc.) and can present them
* to restore access if the server's user database is lost.
*
* Auth-method-agnostic: the attestation binds to a userPublicKey regardless
* of whether it came from ANProto, SSH, Keet, or Noise.
*
* Uses ServerIdentity.sign() for Ed25519 signatures.
*
* See Issue #30 for design rationale.
*/
import { ServerIdentity } from '../p2p/identity/server-identity.js'
export class PermissionAttestation {
/**
* Create a server-signed attestation for a user's permissions.
*
* @param {Object} options
* @param {Object} options.user - User record from Hyperbee
* @param {string} options.userPublicKey - User's public key (hex, any auth method)
* @param {string} options.identityMethod - Auth method: web-identity|ssh-key|keet-identity|noise-key
* @param {string} options.identityId - Identity record ID
* @param {Object} options.serverIdentity - ServerIdentity instance (has sign())
* @returns {Object} Attestation JSON with signature
*/
static create ({ user, userPublicKey, identityMethod, identityId, serverIdentity }) {
if (!user || !userPublicKey || !identityMethod || !serverIdentity) {
throw new Error('Missing required fields for attestation')
}
const permissions = user.globalPermissions || []
const repos = user.repos
? user.repos.map(r => r.name)
: ['*']
const payload = {
version: 1,
epoch: Date.now(),
serverPublicKey: serverIdentity.getPublicKeyHex(),
userPublicKey,
identityMethod,
identityId: identityId || userPublicKey,
userId: user.userId,
permissions,
repos
}
const serialized = PermissionAttestation.serialize(payload)
const signature = serverIdentity.sign(serialized)
return {
...payload,
signature: signature.toString('hex')
}
}
/**
* Verify an attestation was signed by a given server.
*
* @param {Object} attestation - The full attestation JSON (including signature)
* @param {Buffer|string} serverPublicKey - Expected server public key
* @returns {{ valid: boolean, grant: Object|null }}
*/
static verify (attestation, serverPublicKey) {
if (!attestation || !attestation.signature || !attestation.serverPublicKey) {
return { valid: false, grant: null }
}
// Check version
if (attestation.version !== 1) {
return { valid: false, grant: null }
}
// Check server key matches
const expectedKey = typeof serverPublicKey === 'string'
? serverPublicKey
: serverPublicKey.toString('hex')
if (attestation.serverPublicKey !== expectedKey) {
return { valid: false, grant: null }
}
// Extract payload (everything except signature)
const { signature, ...payload } = attestation
const serialized = PermissionAttestation.serialize(payload)
const valid = ServerIdentity.verifySignature(serialized, signature, serverPublicKey)
if (!valid) {
return { valid: false, grant: null }
}
return {
valid: true,
grant: {
userPublicKey: attestation.userPublicKey,
identityMethod: attestation.identityMethod,
identityId: attestation.identityId,
userId: attestation.userId,
permissions: attestation.permissions,
repos: attestation.repos,
epoch: attestation.epoch
}
}
}
/**
* Deterministic JSON serialization for signing.
* Keys are sorted to ensure consistent byte representation.
*
* @param {Object} payload - Object to serialize (without signature)
* @returns {string} Deterministic JSON string
*/
static serialize (payload) {
return JSON.stringify(payload, Object.keys(payload).sort())
}
}
export default PermissionAttestation