diff --git a/src/cli/did-rotation-key-check.ts b/src/cli/did-rotation-key-check.ts new file mode 100644 index 0000000..5cfd534 --- /dev/null +++ b/src/cli/did-rotation-key-check.ts @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +import { parseArgs } from 'node:util'; +import { readFile } from 'node:fs/promises'; +import { getRotationPublicKeyDidKey, RotationKeyInputError } from '../keys.js'; +import { validatePlcDid, DidValidationError } from '../did-validation.js'; +import { checkRotationKey, DidLogFetchError } from '../verify.js'; + +const { values } = parseArgs({ + options: { + did: { + type: 'string', + }, + key: { + type: 'string', + }, + 'key-file': { + type: 'string', + }, + help: { + type: 'boolean', + }, + }, +}); + +if (values.help) { + console.log(`Usage: fair-tools did rotation-key check [options] + +Check if a rotation key is valid for DID operations. + +Valid rotation keys are present in the latest operation in the DID log, not in the DID document. + +Required: + --did The DID to check (did:plc:...) + +Key input (one required): + --key Public key in did:key format (did:key:zQ3sh...) or multibase format (zQ3sh...). + --key-file Read rotation key from file. Accepts a public key or a private keypair. + Public key should be in did:key format (did:key:zQ3sh...) or multibase format (zQ3sh...). + Private key can be in PEM, multibase, or hex format. + +Optional: + --help Show this help message + +Exit codes: + 0 Key is valid (present in latest DID operation) + 1 Key is not valid (not found or DID has no rotation keys) + 2 Error occurred (invalid input, network error, etc.)`); + process.exit(0); +} + +// Validate required options +if (!values.did) { + console.error('Error: Missing required option: --did'); + console.error('Run with --help for usage information.'); + process.exit(2); +} + +if (!values.key && !values['key-file']) { + console.error('Error: Must provide either --key or --key-file'); + console.error('Run with --help for usage information.'); + process.exit(2); +} + +if (values.key && values['key-file']) { + console.error('Error: Cannot specify both --key and --key-file'); + console.error('Run with --help for usage information.'); + process.exit(2); +} + +const did = values.did; + +// Validate DID format +try { + validatePlcDid(did); +} catch (err) { + if (err instanceof DidValidationError) { + console.error(`Error: ${err.message}`); + process.exit(2); + } + throw err; +} + +// Extract the public key in did:key format +let publicKeyDidKey: string; +try { + if (values['key-file']) { + // --key-file accepts both public and private keys + const keyInput = await readFile(values['key-file'], 'utf-8'); + publicKeyDidKey = await getRotationPublicKeyDidKey(keyInput); + } else { + // --key accepts public keys (we'll also parse private keys through the same function) + publicKeyDidKey = await getRotationPublicKeyDidKey(values.key!); + } +} catch (err) { + if (err instanceof RotationKeyInputError) { + console.error(`Error: ${err.message}`); + process.exit(2); + } + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + console.error(`Error reading key file: ${(err as Error).message}`); + process.exit(2); + } + throw err; +} + +// Check if the key is valid for the DID +console.log(`Checking rotation key for ${did}...`); + +let result; +try { + result = await checkRotationKey(did, publicKeyDidKey); +} catch (err) { + if (err instanceof DidLogFetchError) { + console.error(`Error: Failed to fetch DID log: ${err.message}`); + process.exit(2); + } + throw err; +} + +if (result.allKeys.length === 0) { + console.log(`\n❌ No rotation keys found in DID log`); + console.log(`The DID ${did} has no rotation keys in its latest operation.`); + process.exit(1); +} + +if (result.valid) { + console.log(`\n✓ Rotation key is valid`); + console.log(`Key: ${result.publicKeyDidKey}`); + console.log(`This key can be used for DID operations on ${did}`); + process.exit(0); +} else { + console.log(`\n❌ Rotation key is not valid`); + console.log(`Key: ${result.publicKeyDidKey}`); + console.log(`This key is not present in the latest operation of ${did}`); + console.log(`\nValid rotation keys for this DID:`); + for (const key of result.allKeys) { + console.log(` ${key}`); + } + process.exit(1); +} diff --git a/src/cli/fair-tools.ts b/src/cli/fair-tools.ts index f3ec88d..95ec711 100755 --- a/src/cli/fair-tools.ts +++ b/src/cli/fair-tools.ts @@ -61,6 +61,10 @@ const commands: { [key: string]: CommandTree } = { description: 'Revoke a rotation key', load: () => import('./did-rotation-key-revoke.js'), }, + check: { + description: 'Check if a rotation key is valid', + load: () => import('./did-rotation-key-check.js'), + }, }, keys: { migrate: { diff --git a/src/did-validation.ts b/src/did-validation.ts index 2568328..aab42f6 100644 --- a/src/did-validation.ts +++ b/src/did-validation.ts @@ -18,13 +18,13 @@ const DID_PLC_LENGTH = 32; * Prefix for Ed25519 public keys in did:key format (verification keys). * Format: did:key:z6Mk... */ -const ED25519_DID_KEY_PREFIX = 'did:key:z6Mk'; +export const ED25519_DID_KEY_PREFIX = 'did:key:z6Mk'; /** * Prefix for Secp256k1 public keys in did:key format (rotation keys). * Format: did:key:zQ3sh... */ -const SECP256K1_DID_KEY_PREFIX = 'did:key:zQ3sh'; +export const SECP256K1_DID_KEY_PREFIX = 'did:key:zQ3sh'; /** * Expected length of a did:key Ed25519 public key. @@ -36,7 +36,25 @@ const ED25519_DID_KEY_LENGTH = 56; * Expected length of a did:key Secp256k1 compressed public key. * Format: did:key: (8 chars) + multibase 'z' (1 char) + base58btc encoded (multicodec prefix + 33-byte compressed key) = 57 characters total. */ -const SECP256K1_DID_KEY_LENGTH = 57; +export const SECP256K1_DID_KEY_LENGTH = 57; + +/** + * Multibase prefix for Ed25519 public keys (verification keys). + * Format: z6Mk... + */ +export const ED25519_PUBLIC_MULTIBASE_PREFIX = 'z6Mk'; + +/** + * Multibase prefix for Secp256k1 public keys (rotation keys). + * Format: zQ3sh... + */ +export const SECP256K1_PUBLIC_MULTIBASE_PREFIX = 'zQ3sh'; + +/** + * Length of a Secp256k1 public key multibase (without did:key: prefix). + * 'z' (1 char) + base58btc encoded (multicodec prefix + 33-byte compressed key) = 49 characters total. + */ +export const SECP256K1_PUBLIC_MULTIBASE_LENGTH = 49; /** * Validates that a DID has the required did:plc: prefix and correct length. diff --git a/src/keys.ts b/src/keys.ts index b5da74b..62f59ec 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -1,6 +1,18 @@ import { Keypair, Secp256k1Keypair, verifySignature } from '@atproto/crypto'; import { ed25519 } from '@noble/curves/ed25519'; -import { Ed25519Keypair, ED25519_PUBLIC_MULTIBASE_PREFIX, DID_KEY_PREFIX } from './Ed25519Keypair.js'; +import { + Ed25519Keypair, + ED25519_PUBLIC_MULTIBASE_PREFIX as ED25519_MULTIBASE_PREFIX, + DID_KEY_PREFIX, +} from './Ed25519Keypair.js'; +import { + SECP256K1_DID_KEY_PREFIX, + SECP256K1_DID_KEY_LENGTH, + SECP256K1_PUBLIC_MULTIBASE_PREFIX, + SECP256K1_PUBLIC_MULTIBASE_LENGTH, + ED25519_DID_KEY_PREFIX, + ED25519_PUBLIC_MULTIBASE_PREFIX, +} from './did-validation.js'; export interface KeyPairBundle { publicKey: string; @@ -118,6 +130,11 @@ export async function verifyWithRotationKey( */ export class VerificationKeyInputError extends Error {} +/** + * Error thrown when a rotation key input is invalid. + */ +export class RotationKeyInputError extends Error {} + /** * Parses a public key input and returns the multibase format. * @@ -173,7 +190,7 @@ export async function getVerificationPublicKeyMultibase(keyInput: string): Promi return multibase; } - if (trimmed.startsWith(ED25519_PUBLIC_MULTIBASE_PREFIX)) { + if (trimmed.startsWith(ED25519_MULTIBASE_PREFIX)) { try { await Ed25519Keypair.fromPublicKeyMultibase(trimmed); } catch (err) { @@ -196,3 +213,73 @@ export async function getVerificationPublicKeyMultibase(keyInput: string): Promi 'Unrecognized key format. Expected a public key (did:key:z6Mk...) or private key (PEM, multibase, or hex)', ); } + +/** + * Extracts the public key did:key from a rotation key input. + * + * Accepts: + * - Public key in did:key format (did:key:zQ3sh...) + * - Public key multibase (zQ3sh...) + * - Private key in PEM, multibase, or hex format (derives the public key) + * + * @param keyInput - The key input string + * @returns The public key in did:key format (did:key:zQ3sh...) + * @throws {RotationKeyInputError} If the key format is unrecognized or invalid + */ +export async function getRotationPublicKeyDidKey(keyInput: string): Promise { + const { isMultibaseRotationKey, isECPrivateKeyPEM, isHexPrivateKey, parseAsRotationKey } = + await import('./signing.js'); + + const trimmed = keyInput.trim(); + + // Check if it's already in did:key format + if (trimmed.startsWith(DID_KEY_PREFIX)) { + // Validate it's a rotation key, not a verification key + if (trimmed.startsWith(ED25519_DID_KEY_PREFIX)) { + throw new RotationKeyInputError( + 'Wrong key type. This looks like a verification key, but a rotation key is required.', + ); + } + if (!trimmed.startsWith(SECP256K1_DID_KEY_PREFIX)) { + throw new RotationKeyInputError( + `Invalid rotation key format. Key must start with '${SECP256K1_DID_KEY_PREFIX}'.`, + ); + } + // Validate length + if (trimmed.length !== SECP256K1_DID_KEY_LENGTH) { + throw new RotationKeyInputError('Invalid rotation key length.'); + } + return trimmed; + } + + // Check if it's a multibase public key + if (trimmed.startsWith(SECP256K1_PUBLIC_MULTIBASE_PREFIX)) { + // Validate length + if (trimmed.length !== SECP256K1_PUBLIC_MULTIBASE_LENGTH) { + throw new RotationKeyInputError('Invalid rotation key multibase length.'); + } + return DID_KEY_PREFIX + trimmed; + } + + // Check if it's a verification key multibase (wrong type) + if (trimmed.startsWith(ED25519_PUBLIC_MULTIBASE_PREFIX)) { + throw new RotationKeyInputError( + 'Wrong key type. This looks like a verification key, but a rotation key is required.', + ); + } + + // Try to parse as private key and derive public key + if (isECPrivateKeyPEM(trimmed) || isMultibaseRotationKey(trimmed) || isHexPrivateKey(trimmed)) { + try { + const privateKeyHex = parseAsRotationKey(trimmed); + const { keypair } = await importRotationKeyPair(privateKeyHex); + return keypair.did(); + } catch (err) { + throw new RotationKeyInputError(`Invalid private key: ${(err as Error).message}`); + } + } + + throw new RotationKeyInputError( + 'Unrecognized key format. Expected a public key (did:key:zQ3sh...) or private key (PEM, multibase, or hex)', + ); +} diff --git a/src/signing.ts b/src/signing.ts index a7eb342..717eed5 100644 --- a/src/signing.ts +++ b/src/signing.ts @@ -259,7 +259,7 @@ export function isMultibaseVerificationKey(content: string): boolean { * @returns {string} - The hex key * @throws {SigningKeyError} If the format is invalid or unrecognized */ -function parseAsRotationKey(content: string): string { +export function parseAsRotationKey(content: string): string { const trimmed = content.trim(); // Try PEM format first (EC PRIVATE KEY for secp256k1) diff --git a/src/verify.ts b/src/verify.ts index b112a76..d23079b 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -10,7 +10,7 @@ import { Ed25519Keypair } from './Ed25519Keypair.js'; import { fetchOptions } from './utils.js'; import { METADATA_CONTEXT, verifyArtifact } from './metadata.js'; import { FAIR_SERVICE_TYPE, PLC_DIRECTORY_URL, createPlcClient } from './plc.js'; -import { validateDidLog, DidLogFetchError, DidLogValidationError } from './plc-log.js'; +import { validateDidLog, DidLogFetchError, DidLogValidationError, fetchDidLog } from './plc-log.js'; import { getFairAlias, verifyDomainDid, @@ -855,6 +855,52 @@ export async function checkVerificationKey( }; } +/** + * Result of checking if a rotation key is valid for a DID. + */ +export interface CheckRotationKeyResult { + valid: boolean; + publicKeyDidKey: string; + allKeys: string[]; +} + +/** + * Checks if a rotation key is valid for a DID. + * + * A rotation key is valid if it's present in the latest operation in the DID log, + * not in the DID document. + * + * @param did - The DID to check (did:plc:...) + * @param publicKeyDidKey - The public key in did:key format to check (did:key:zQ3sh...) + * @param plcUrl - Optional PLC directory URL + * @returns Result indicating if the key is valid and all rotation keys in the latest operation + * @throws {DidLogFetchError} If the DID log cannot be fetched + */ +export async function checkRotationKey( + did: string, + publicKeyDidKey: string, + plcUrl = PLC_DIRECTORY_URL, +): Promise { + // Fetch the DID operation log + const ops = await fetchDidLog(did, plcUrl); + + if (ops.length === 0) { + throw new DidLogFetchError('DID log is empty'); + } + + // Get rotation keys from the latest operation + const latestOp = ops[ops.length - 1]; + const rotationKeys = latestOp.rotationKeys || []; + + const isValid = rotationKeys.includes(publicKeyDidKey); + + return { + valid: isValid, + publicKeyDidKey, + allKeys: rotationKeys, + }; +} + // Re-export error types for consumers export { DidLogFetchError, DidLogValidationError } from './plc-log.js'; export { diff --git a/test/keys.test.ts b/test/keys.test.ts index 04325e7..013b454 100644 --- a/test/keys.test.ts +++ b/test/keys.test.ts @@ -9,9 +9,11 @@ import { verifyWithRotationKey, getVerificationPublicKeyMultibase, parsePublicKeyOnly, + getRotationPublicKeyDidKey, VerificationKeyInputError, + RotationKeyInputError, } from '../src/keys.js'; -import { encodeVerificationKey } from '../src/keyfile.js'; +import { encodeVerificationKey, encodeRotationKey } from '../src/keyfile.js'; import { base58btc } from 'multiformats/bases/base58'; describe('generate verification key pair', () => { @@ -409,3 +411,98 @@ describe('parsePublicKeyOnly', () => { }); }); }); + +describe('getRotationPublicKeyDidKey', () => { + it('returns did:key format as-is when valid', async () => { + const keys = await generateRotationKeyPair(); + + const result = await getRotationPublicKeyDidKey(keys.publicKey); + + assert.strictEqual(result, keys.publicKey); + }); + + it('converts multibase to did:key format', async () => { + const keys = await generateRotationKeyPair(); + const multibase = keys.publicKey.replace('did:key:', ''); + + const result = await getRotationPublicKeyDidKey(multibase); + + assert.strictEqual(result, keys.publicKey); + }); + + it('derives public key from hex private key', async () => { + const keys = await generateRotationKeyPair(); + const hexPrivateKey = Buffer.from(keys.privateKey).toString('hex'); + + const result = await getRotationPublicKeyDidKey(hexPrivateKey); + + assert.strictEqual(result, keys.publicKey); + }); + + it('derives public key from PEM private key', async () => { + const keys = await generateRotationKeyPair(); + const pemPrivateKey = encodeRotationKey(keys.privateKey); + + const result = await getRotationPublicKeyDidKey(pemPrivateKey); + + assert.strictEqual(result, keys.publicKey); + }); + + it('derives public key from multibase private key', async () => { + const keys = await generateRotationKeyPair(); + + // secp256k1 multibase format + const SECP256K1_PRIV_PREFIX = new Uint8Array([0x81, 0x26]); + const multibaseKey = Buffer.concat([Buffer.from(SECP256K1_PRIV_PREFIX), Buffer.from(keys.privateKey)]); + const multibasePrivateKey = base58btc.encode(multibaseKey); + + const result = await getRotationPublicKeyDidKey(multibasePrivateKey); + + assert.strictEqual(result, keys.publicKey); + }); + + it('trims whitespace from input', async () => { + const keys = await generateRotationKeyPair(); + + const result = await getRotationPublicKeyDidKey(' ' + keys.publicKey + '\n'); + + assert.strictEqual(result, keys.publicKey); + }); + + it('throws for invalid did:key format', async () => { + await assert.rejects(getRotationPublicKeyDidKey('did:key:invalid'), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Invalid rotation key/); + return true; + }); + }); + + it('throws for verification key (wrong key type)', async () => { + const verificationKeys = await generateVerificationKeyPair(); + + await assert.rejects(getRotationPublicKeyDidKey(verificationKeys.publicKey), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Wrong key type.*verification key/); + return true; + }); + }); + + it('throws for verification key multibase (wrong key type)', async () => { + const verificationKeys = await generateVerificationKeyPair(); + const multibase = verificationKeys.keypair.publicKeyStr(); + + await assert.rejects(getRotationPublicKeyDidKey(multibase), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Wrong key type.*verification key/); + return true; + }); + }); + + it('throws for unrecognized key format', async () => { + await assert.rejects(getRotationPublicKeyDidKey('not-a-valid-key-format'), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Unrecognized key format/); + return true; + }); + }); +});