Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/cli/did-rotation-key-check.ts
Original file line number Diff line number Diff line change
@@ -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 <did> The DID to check (did:plc:...)

Key input (one required):
--key <key> Public key in did:key format (did:key:zQ3sh...) or multibase format (zQ3sh...).
--key-file <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);
}
4 changes: 4 additions & 0 deletions src/cli/fair-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
24 changes: 21 additions & 3 deletions src/did-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
91 changes: 89 additions & 2 deletions src/keys.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Keypair = Keypair> {
publicKey: string;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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) {
Expand All @@ -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<string> {
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)',
);
}
2 changes: 1 addition & 1 deletion src/signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 47 additions & 1 deletion src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<CheckRotationKeyResult> {
// 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 {
Expand Down
Loading