diff --git a/README.md b/README.md index 4939367..6f96fd4 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ fair-tools did verification-key add Add a verification key fair-tools did verification-key check Check if a verification key is valid for a DID fair-tools did verification-key revoke Revoke a verification key fair-tools did rotation-key add Add a rotation key +fair-tools did rotation-key check Check if a rotation key is valid for a DID fair-tools did rotation-key revoke Revoke a rotation key fair-tools did log verify Validate a DID operation log from genesis fair-tools did aka add Add a URL to the alsoKnownAs field @@ -251,7 +252,7 @@ Use `--output-file` to save the new key to a different file instead of the signi ### Check verification key -Checks if a verification key is present in the DID document's verification methods. +Checks if a verification key is valid by checking that it's present in the DID document's verification methods. ```bash fair-tools did verification-key check \ @@ -285,6 +286,31 @@ fair-tools did rotation-key add \ Use `--output-file` to save the new key to a different file instead of the signing file. +### Check rotation key + +Checks if a rotation key is valid by checking that it's present in the latest operation of the DID log. + +```bash +fair-tools did rotation-key check \ + --did did:plc:xxx \ + --key did:key:zQ3sh... +``` + +You can also provide the key via file or environment variable: + +```bash +# From a file (accepts public key or private keypair) +fair-tools did rotation-key check \ + --did did:plc:xxx \ + --key-file ./key.pem + +# From environment variable +FAIR_ROTATION_KEY=zQ3sh... fair-tools did rotation-key check \ + --did did:plc:xxx +``` + +If neither `--key` nor `--key-file` is provided, uses `FAIR_ROTATION_KEY` environment variable. + ### Revoke verification key Revokes a verification key from a DID. diff --git a/src/Ed25519Keypair.ts b/src/Ed25519Keypair.ts index 7e18adf..45d4fa7 100644 --- a/src/Ed25519Keypair.ts +++ b/src/Ed25519Keypair.ts @@ -13,16 +13,6 @@ export const ED25519_PUBLIC_PREFIX = new Uint8Array([0xed, 0x01]); */ export const ED25519_PUBLIC_MULTIBASE_PREFIX = 'z6Mk'; -/** - * Prefix for did:key URIs. - */ -export const DID_KEY_PREFIX = 'did:key:'; - -/** - * Multicodec prefix for Ed25519 private keys. - */ -export const ED25519_PRIVATE_PREFIX = new Uint8Array([0x80, 0x26]); - /** * Ed25519 verification keypair. */ diff --git a/src/cli/did-rotation-key-check.ts b/src/cli/did-rotation-key-check.ts new file mode 100644 index 0000000..4738da1 --- /dev/null +++ b/src/cli/did-rotation-key-check.ts @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +import { parseArgs } from 'node:util'; +import { readFile } from 'node:fs/promises'; +import { getRotationPublicKeyMultibase, parseRotationPublicKeyOnly, RotationKeyInputError } from '../keys.js'; +import { validatePlcDid, DidValidationError } from '../did-validation.js'; +import { checkRotationKey, DidLogFetchError, DidLogValidationError } 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 signing PLC operations. + +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. + + If neither --key nor --key-file is provided, uses FAIR_ROTATION_KEY environment variable. + +Optional: + --help Show this help message + +Exit codes: + 0 Key is valid + 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: 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 multibase +let publicKeyMultibase: string; +try { + if (values['key-file']) { + // --key-file accepts both public and private keys + const keyInput = await readFile(values['key-file'], 'utf-8'); + publicKeyMultibase = await getRotationPublicKeyMultibase(keyInput); + } else if (values.key) { + // --key only accepts public keys + publicKeyMultibase = await parseRotationPublicKeyOnly(values.key); + } else if (process.env.FAIR_ROTATION_KEY) { + // FAIR_ROTATION_KEY env var - handles like --key-file + publicKeyMultibase = await getRotationPublicKeyMultibase(process.env.FAIR_ROTATION_KEY); + } else { + console.error('Error: Must provide --key, --key-file, or set FAIR_ROTATION_KEY environment variable'); + console.error('Run with --help for usage information.'); + process.exit(2); + } +} 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, publicKeyMultibase); +} catch (err) { + if (err instanceof DidLogFetchError) { + console.error(`Error: Failed to fetch DID log: ${err.message}`); + process.exit(2); + } + if (err instanceof DidLogValidationError) { + console.error(`Error: DID log validation failed: ${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.`); + process.exit(1); +} + +if (result.valid) { + console.log(`\n✓ Rotation key is valid`); + console.log(`Public key: did:key:${result.publicKeyMultibase}`); + console.log(`This key can be used to sign PLC operations for ${did}`); + process.exit(0); +} else { + console.log(`\n❌ Rotation key is not valid`); + console.log(`Public key: did:key:${result.publicKeyMultibase}`); + console.log(`This key is not present in the latest operation of the DID log for ${did}`); + console.log(`\nValid rotation keys for this DID:`); + for (const key of result.allKeys) { + console.log(` did:key:${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..8861fa6 100644 --- a/src/did-validation.ts +++ b/src/did-validation.ts @@ -14,29 +14,55 @@ export class PublicKeyValidationError extends Error {} */ const DID_PLC_LENGTH = 32; +/** + * Prefix for did:key URIs. + */ +export const DID_KEY_PREFIX = 'did:key:'; + /** * 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. * Format: did:key: (8 chars) + multibase 'z' (1 char) + base58btc encoded (multicodec prefix + 32-byte key) = 56 characters total. */ -const ED25519_DID_KEY_LENGTH = 56; +export 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 Secp256k1 public keys (rotation keys). + * Format: zQ3sh... + */ +export const SECP256K1_PUBLIC_MULTIBASE_PREFIX = 'zQ3sh'; + +/** + * Multicodec prefix for secp256k1 compressed public keys (rotation keys). + */ +export const SECP256K1_PUBLIC_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]); + +/** + * Multicodec prefix for secp256k1 private keys (rotation keys). + */ +export const SECP256K1_PRIVATE_MULTICODEC_PREFIX = new Uint8Array([0x81, 0x26]); + +/** + * Multicodec prefix for Ed25519 private keys (verification keys). + */ +export const ED25519_PRIVATE_MULTICODEC_PREFIX = new Uint8Array([0x80, 0x26]); /** * 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..0413d16 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -1,6 +1,11 @@ -import { Keypair, Secp256k1Keypair, verifySignature } from '@atproto/crypto'; +import { Keypair, Secp256k1Keypair, verifySignature, multibaseToBytes } 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 } from './Ed25519Keypair.js'; +import { + DID_KEY_PREFIX, + SECP256K1_PUBLIC_MULTIBASE_PREFIX, + SECP256K1_PUBLIC_MULTICODEC_PREFIX, +} from './did-validation.js'; export interface KeyPairBundle { publicKey: string; @@ -118,6 +123,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. * @@ -196,3 +206,123 @@ export async function getVerificationPublicKeyMultibase(keyInput: string): Promi 'Unrecognized key format. Expected a public key (did:key:z6Mk...) or private key (PEM, multibase, or hex)', ); } + +/** + * Validates a Secp256k1 public key multibase. + * + * @param multibase - The multibase string to validate (zQ3sh...) + * @throws {RotationKeyInputError} If the multibase is invalid + */ +function validateSecp256k1PublicKeyMultibase(multibase: string): void { + let decoded: Uint8Array; + try { + decoded = multibaseToBytes(multibase); + } catch (err) { + throw new RotationKeyInputError(`Invalid multibase encoding: ${(err as Error).message}`); + } + + // Check minimum length (2-byte prefix + at least some key data) + if (decoded.length < SECP256K1_PUBLIC_MULTICODEC_PREFIX.length) { + throw new RotationKeyInputError( + `Invalid key length: expected at least ${SECP256K1_PUBLIC_MULTICODEC_PREFIX.length} bytes, got ${decoded.length} bytes`, + ); + } + + // Check for secp256k1 multicodec prefix (0xe701) + if (decoded[0] !== SECP256K1_PUBLIC_MULTICODEC_PREFIX[0] || decoded[1] !== SECP256K1_PUBLIC_MULTICODEC_PREFIX[1]) { + throw new RotationKeyInputError( + `Unsupported key type: expected secp256k1 multicodec prefix (0xe701), ` + + `got 0x${decoded[0].toString(16).padStart(2, '0')}${decoded[1].toString(16).padStart(2, '0')}`, + ); + } + + // Validate total length (2-byte prefix + 33-byte compressed public key = 35 bytes) + const expectedLength = SECP256K1_PUBLIC_MULTICODEC_PREFIX.length + 33; + if (decoded.length !== expectedLength) { + throw new RotationKeyInputError( + `Invalid key length: expected ${expectedLength} bytes (2-byte prefix + 33-byte key), ` + + `got ${decoded.length} bytes`, + ); + } +} + +/** + * Parses a rotation public key input and returns the multibase format. + * + * Only accepts public keys: + * - did:key format (did:key:zQ3sh...) + * - Multibase format (zQ3sh...) + * + * @param keyInput - The public key input string + * @returns The public key multibase (zQ3sh...) + * @throws {RotationKeyInputError} If the key format is unrecognized, invalid, or a private key + */ +export async function parseRotationPublicKeyOnly(keyInput: string): Promise { + const { isMultibaseRotationKey, isECPrivateKeyPEM, isHexPrivateKey } = await import('./signing.js'); + + const trimmed = keyInput.trim(); + + // Check if it looks like a private key and reject with a specific error + if (isECPrivateKeyPEM(trimmed) || isMultibaseRotationKey(trimmed) || isHexPrivateKey(trimmed)) { + throw new RotationKeyInputError( + 'Private key provided but only public keys are accepted. Use --key-file to provide a private key from a file.', + ); + } + + // Delegate to getRotationPublicKeyMultibase for public key parsing + return getRotationPublicKeyMultibase(trimmed); +} + +/** + * Extracts the public key multibase 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 multibase (zQ3sh...) + * @throws {RotationKeyInputError} If the key format is unrecognized or invalid + */ +export async function getRotationPublicKeyMultibase(keyInput: string): Promise { + const { isMultibaseRotationKey, isECPrivateKeyPEM, isHexPrivateKey, parseAsRotationKey } = + await import('./signing.js'); + + const trimmed = keyInput.trim(); + + // Handle did:key format + if (trimmed.startsWith(DID_KEY_PREFIX)) { + const multibase = trimmed.slice(DID_KEY_PREFIX.length); + if (!multibase.startsWith(SECP256K1_PUBLIC_MULTIBASE_PREFIX)) { + throw new RotationKeyInputError( + `Invalid rotation key format. Expected a Secp256k1 key starting with '${SECP256K1_PUBLIC_MULTIBASE_PREFIX}', got '${multibase.slice(0, SECP256K1_PUBLIC_MULTIBASE_PREFIX.length)}...'`, + ); + } + // Validate by decoding and checking structure + validateSecp256k1PublicKeyMultibase(multibase); + return multibase; + } + + // Handle multibase format (zQ3sh...) + if (trimmed.startsWith(SECP256K1_PUBLIC_MULTIBASE_PREFIX)) { + // Validate by decoding and checking structure + validateSecp256k1PublicKeyMultibase(trimmed); + return trimmed; + } + + // Handle private key formats (derive the public key) + if (isECPrivateKeyPEM(trimmed) || isMultibaseRotationKey(trimmed) || isHexPrivateKey(trimmed)) { + try { + const privateKeyHex = parseAsRotationKey(trimmed); + const { keypair } = await importRotationKeyPair(privateKeyHex); + return keypair.did().slice(DID_KEY_PREFIX.length); + } 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..4c46945 100644 --- a/src/signing.ts +++ b/src/signing.ts @@ -1,21 +1,10 @@ import crypto from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { base58btc } from 'multiformats/bases/base58'; +import { SECP256K1_PRIVATE_MULTICODEC_PREFIX, ED25519_PRIVATE_MULTICODEC_PREFIX } from './did-validation.js'; -/** - * Multicodec prefix for secp256k1 private keys (rotation keys). - * Used when reading multibase-encoded keys for interoperability with FAIR Beacon. - */ -export const SECP256K1_PRIV_PREFIX = new Uint8Array([0x81, 0x26]); - -/** - * Multicodec prefix for ed25519 private keys (verification keys). - * Used when reading multibase-encoded keys for interoperability with FAIR Beacon. - */ -export const ED25519_PRIV_PREFIX = new Uint8Array([0x80, 0x26]); - -const SECP256K1_PRIV_PREFIX_HEX = Buffer.from(SECP256K1_PRIV_PREFIX).toString('hex'); -const ED25519_PRIV_PREFIX_HEX = Buffer.from(ED25519_PRIV_PREFIX).toString('hex'); +const SECP256K1_PRIV_PREFIX_HEX = Buffer.from(SECP256K1_PRIVATE_MULTICODEC_PREFIX).toString('hex'); +const ED25519_PRIV_PREFIX_HEX = Buffer.from(ED25519_PRIVATE_MULTICODEC_PREFIX).toString('hex'); /** * PEM header for EC private keys (SEC1 format, used for secp256k1 rotation keys). @@ -259,7 +248,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..b76a8b9 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -7,6 +7,7 @@ import { createHash, timingSafeEqual } from 'node:crypto'; import { DidDocument } from '@did-plc/lib'; import { Ed25519Keypair } from './Ed25519Keypair.js'; +import { DID_KEY_PREFIX } from './did-validation.js'; import { fetchOptions } from './utils.js'; import { METADATA_CONTEXT, verifyArtifact } from './metadata.js'; import { FAIR_SERVICE_TYPE, PLC_DIRECTORY_URL, createPlcClient } from './plc.js'; @@ -855,6 +856,62 @@ export async function checkVerificationKey( }; } +/** + * Result of checking if a rotation key is valid for a DID. + */ +export interface CheckRotationKeyResult { + valid: boolean; + publicKeyMultibase: 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 of the DID log. + * + * @param did - The DID to check (did:plc:...) + * @param publicKeyMultibase - The public key multibase to check (zQ3sh...) + * @param plcUrl - Optional PLC directory URL + * @returns Result indicating if the key is valid and the list of current rotation keys (as multibase strings) + * @throws {DidLogFetchError} If the DID log cannot be fetched + */ +export async function checkRotationKey( + did: string, + publicKeyMultibase: string, + plcUrl = PLC_DIRECTORY_URL, +): Promise { + const { fetchDidLog } = await import('./plc-log.js'); + + const didKey = `did:key:${publicKeyMultibase}`; + const ops = await fetchDidLog(did, plcUrl); + + if (ops.length === 0) { + return { + valid: false, + publicKeyMultibase, + allKeys: [], + }; + } + + // Get rotation keys from the latest operation + const latestOp = ops[ops.length - 1]; + const rotationKeys = latestOp.rotationKeys || []; + + const isValid = rotationKeys.includes(didKey); + + // Strip did:key: prefix from rotation keys for consistency with publicKeyMultibase + const allKeysMultibase = rotationKeys.map((key) => + key.startsWith(DID_KEY_PREFIX) ? key.slice(DID_KEY_PREFIX.length) : key, + ); + + return { + valid: isValid, + publicKeyMultibase, + allKeys: allKeysMultibase, + }; +} + // 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..a38b77f 100644 --- a/test/keys.test.ts +++ b/test/keys.test.ts @@ -10,8 +10,11 @@ import { getVerificationPublicKeyMultibase, parsePublicKeyOnly, VerificationKeyInputError, + getRotationPublicKeyMultibase, + parseRotationPublicKeyOnly, + 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 +412,191 @@ describe('parsePublicKeyOnly', () => { }); }); }); + +describe('getRotationPublicKeyMultibase', () => { + it('extracts multibase from did:key format', async () => { + const keys = await generateRotationKeyPair(); + const expectedMultibase = keys.publicKey.replace('did:key:', ''); + + const result = await getRotationPublicKeyMultibase(keys.publicKey); + + assert.strictEqual(result, expectedMultibase); + }); + + it('returns raw multibase as-is when valid', async () => { + const keys = await generateRotationKeyPair(); + const multibase = keys.publicKey.replace('did:key:', ''); + + const result = await getRotationPublicKeyMultibase(multibase); + + assert.strictEqual(result, multibase); + }); + + it('derives public key from hex private key', async () => { + const keys = await generateRotationKeyPair(); + const hexPrivateKey = Buffer.from(keys.privateKey).toString('hex'); + const expectedMultibase = keys.publicKey.replace('did:key:', ''); + + const result = await getRotationPublicKeyMultibase(hexPrivateKey); + + assert.strictEqual(result, expectedMultibase); + }); + + it('derives public key from PEM private key', async () => { + const keys = await generateRotationKeyPair(); + const pemPrivateKey = encodeRotationKey(keys.privateKey); + const expectedMultibase = keys.publicKey.replace('did:key:', ''); + + const result = await getRotationPublicKeyMultibase(pemPrivateKey); + + assert.strictEqual(result, expectedMultibase); + }); + + it('derives public key from multibase private key', async () => { + const keys = await generateRotationKeyPair(); + const expectedMultibase = keys.publicKey.replace('did:key:', ''); + + // Secp256k1 private key multibase format + const SECP256K1_PRIV_PREFIX = new Uint8Array([0x81, 0x26]); + const prefixedKey = Buffer.concat([Buffer.from(SECP256K1_PRIV_PREFIX), Buffer.from(keys.privateKey)]); + const multibasePrivateKey = base58btc.encode(prefixedKey); + + const result = await getRotationPublicKeyMultibase(multibasePrivateKey); + + assert.strictEqual(result, expectedMultibase); + }); + + it('trims whitespace from input', async () => { + const keys = await generateRotationKeyPair(); + const expectedMultibase = keys.publicKey.replace('did:key:', ''); + + const result = await getRotationPublicKeyMultibase(' ' + keys.publicKey + '\n'); + + assert.strictEqual(result, expectedMultibase); + }); + + it('throws for invalid did:key format', async () => { + await assert.rejects(getRotationPublicKeyMultibase('did:key:zQ3shInvalidKey'), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Invalid multibase encoding/); + return true; + }); + }); + + it('throws for invalid multibase public key', async () => { + await assert.rejects(getRotationPublicKeyMultibase('zQ3shInvalidKey'), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Invalid multibase encoding/); + return true; + }); + }); + + it('throws for unrecognized key format', async () => { + await assert.rejects(getRotationPublicKeyMultibase('not-a-valid-key-format'), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Unrecognized key format/); + return true; + }); + }); + + it('throws for verification key (wrong key type)', async () => { + const verificationKeys = await generateVerificationKeyPair(); + + await assert.rejects(getRotationPublicKeyMultibase(verificationKeys.publicKey), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Invalid rotation key format/); + return true; + }); + }); +}); + +describe('parseRotationPublicKeyOnly', () => { + it('extracts multibase from did:key format', async () => { + const keys = await generateRotationKeyPair(); + const expectedMultibase = keys.publicKey.replace('did:key:', ''); + + const result = await parseRotationPublicKeyOnly(keys.publicKey); + + assert.strictEqual(result, expectedMultibase); + }); + + it('returns raw multibase as-is when valid', async () => { + const keys = await generateRotationKeyPair(); + const multibase = keys.publicKey.replace('did:key:', ''); + + const result = await parseRotationPublicKeyOnly(multibase); + + assert.strictEqual(result, multibase); + }); + + it('throws for hex private key', async () => { + const keys = await generateRotationKeyPair(); + const hexPrivateKey = Buffer.from(keys.privateKey).toString('hex'); + + await assert.rejects(parseRotationPublicKeyOnly(hexPrivateKey), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Private key provided but only public keys are accepted/); + return true; + }); + }); + + it('throws for PEM private key', async () => { + const keys = await generateRotationKeyPair(); + const pemPrivateKey = encodeRotationKey(keys.privateKey); + + await assert.rejects(parseRotationPublicKeyOnly(pemPrivateKey), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Private key provided but only public keys are accepted/); + return true; + }); + }); + + it('throws for multibase private key', async () => { + const keys = await generateRotationKeyPair(); + + // Secp256k1 private key multibase format + const SECP256K1_PRIV_PREFIX = new Uint8Array([0x81, 0x26]); + const prefixedKey = Buffer.concat([Buffer.from(SECP256K1_PRIV_PREFIX), Buffer.from(keys.privateKey)]); + const multibasePrivateKey = base58btc.encode(prefixedKey); + + await assert.rejects(parseRotationPublicKeyOnly(multibasePrivateKey), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Private key provided but only public keys are accepted/); + return true; + }); + }); + + it('throws for invalid did:key format', async () => { + await assert.rejects(parseRotationPublicKeyOnly('did:key:zQ3shInvalidKey'), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Invalid multibase encoding/); + return true; + }); + }); + + it('throws for invalid multibase public key', async () => { + await assert.rejects(parseRotationPublicKeyOnly('zQ3shInvalidKey'), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Invalid multibase encoding/); + return true; + }); + }); + + it('throws for unrecognized key format', async () => { + await assert.rejects(parseRotationPublicKeyOnly('not-a-valid-key-format'), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Unrecognized key format/); + return true; + }); + }); + + it('throws for verification key (wrong key type)', async () => { + const verificationKeys = await generateVerificationKeyPair(); + + await assert.rejects(parseRotationPublicKeyOnly(verificationKeys.publicKey), (err) => { + assert(err instanceof RotationKeyInputError); + assert.match(err.message, /Invalid rotation key format/); + return true; + }); + }); +}); diff --git a/test/verify.test.ts b/test/verify.test.ts index 4cc3ad0..a867a41 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -9,14 +9,15 @@ import { requireFairServices, extractDomainFromAlias, buildAliasResult, + checkRotationKey, ChecksumVerificationError, SignatureVerificationError, MetadataVerificationError, NoServicesError, } from '../src/verify.js'; -import type { FetchAliasResult, VerifyDomainResult } from '../src/verify.js'; +import type { FetchAliasResult, VerifyDomainResult, CheckRotationKeyResult } from '../src/verify.js'; import type { DidDocument } from '@did-plc/lib'; -import { generateVerificationKeyPair } from '../src/keys.js'; +import { generateVerificationKeyPair, generateRotationKeyPair } from '../src/keys.js'; import { Ed25519Keypair } from '../src/Ed25519Keypair.js'; import { signArtifact, METADATA_CONTEXT } from '../src/metadata.js'; import { bytesToMultibase } from '@atproto/crypto'; @@ -590,3 +591,38 @@ describe('buildAliasResult', () => { assert.strictEqual(result.domain, 'example.com'); }); }); + +describe('checkRotationKey', () => { + it('returns CheckRotationKeyResult type with expected fields', async () => { + // This test verifies the interface of the function + // Full integration tests would require network access to fetch DID logs + + // Type check - ensure the result type has the expected structure + const mockResult: CheckRotationKeyResult = { + valid: true, + publicKeyMultibase: 'zQ3shTest', + allKeys: ['zQ3shTest'], + }; + + assert.strictEqual(typeof mockResult.valid, 'boolean'); + assert.strictEqual(typeof mockResult.publicKeyMultibase, 'string'); + assert.ok(Array.isArray(mockResult.allKeys)); + }); + + it('function is exported and callable', () => { + // Verify the function is exported and has the expected signature + assert.strictEqual(typeof checkRotationKey, 'function'); + }); + + it('accepts a rotation key public key format', async () => { + // Generate a valid rotation key to ensure the format is correct + const keys = await generateRotationKeyPair(); + const multibase = keys.publicKey.replace('did:key:', ''); + + // Verify the key starts with the expected prefix + assert.ok( + multibase.startsWith('zQ3sh'), + `Expected rotation key multibase to start with 'zQ3sh', got '${multibase.slice(0, 5)}'`, + ); + }); +});