Skip to content

Conversation

@schuettc
Copy link

Fix key attachment bug and add dual-key support for ActivityPub federation

Description

This PR addresses a bug that prevents the Ghost ActivityPub implementation from federating with other ActivityPub servers. The issue stems from cryptographic keys not being properly attached to the Person object, causing authentication failures when other servers attempt to verify signatures.

Additionally, this PR enhances compatibility across the Fediverse by implementing dual-key support (both Ed25519 and RSA), ensuring broader interoperability with different ActivityPub implementations.

The Problem

Current Behavior

The current implementation uses publicKeys (plural) property which is not recognized by Fedify:

publicKeys: (await ctx.getActorKeyPairs(identifier)).map(
    (key) => key.cryptographicKey,
),

This results in:

  • Keys being generated and stored but not properly attached to the Person object
  • Authentication failures when other servers attempt to verify HTTP signatures
  • Follow requests failing silently
  • Posts not federating to other servers
  • WebFinger discovery working but actual federation failing

Root Cause

According to the Fedify documentation, the Person class expects:

  • publicKey (singular) - Contains a CryptographicKey instance for HTTP Signatures
  • assertionMethods (plural) - An array of Multikey instances for Object Integrity Proofs

The current code uses publicKeys (plural) which is not a valid Person property in Fedify.

The Solution

1. Fixed Key Attachment

Updated actorDispatcher to use the correct Fedify properties:

// Get key pairs for proper attachment
const keyPairs = await ctx.getActorKeyPairs(identifier);

const person = new Person({
    // ... other properties ...

    // FIX: Use correct Fedify properties for keys
    publicKey: keyPairs[0]?.cryptographicKey, // For HTTP Signatures
    assertionMethods: keyPairs.map((kp) => kp.multikey), // For Object Integrity Proofs
});

2. Dual-Key Support

Implemented generation of both Ed25519 and RSA keys for maximum compatibility:

const ed25519Keys = await generateCryptoKeyPair('Ed25519');
const rsaKeys = await generateCryptoKeyPair('RSASSA-PKCS1-v1_5');
return [ed25519Keys, rsaKeys];

3. Backwards Compatibility

The implementation maintains full backwards compatibility:

  • Existing single-key accounts continue to work
  • New accounts automatically get dual keys
  • No database migration required
  • The keypairDispatcher handles both formats transparently

Changes Made

Files Modified (6 files total)

  1. src/dispatchers.ts

    • Fixed actorDispatcher to attach keys using correct properties
    • Updated keypairDispatcher to handle dual-key JSON format
  2. src/account/account.service.ts

    • Modified key generation to create both Ed25519 and RSA keys
    • Updated account creation to handle dual keys
    • Fixed duplicate account handling for dual keys
  3. src/account/account.repository.knex.ts

    • Added support for storing dual keys in JSON array format
    • Maintains backwards compatibility with single-key format
  4. src/account/account.service.unit.test.ts

    • Updated type signature to match new key array format
  5. src/test/account-entity-test-helpers.ts

    • Updated test helpers to work with key arrays
  6. src/test/crypto-key-pair.ts

    • Modified test key generation to create dual keys

Testing

Automated Tests

All tests pass with minimal modifications to support key arrays:

  • ✅ Type checking (yarn test:types)
  • ✅ Unit tests (yarn test:unit)
  • ✅ Integration tests (yarn test:integration)

Modified test files to support dual-key arrays:

  • src/test/crypto-key-pair.ts - Generate dual keys for tests
  • src/test/account-entity-test-helpers.ts - Use first key from array
  • src/account/account.service.unit.test.ts - Updated type signature

Production Verification

These changes are running in production at https://subaud.io:

  1. Keys properly attached:
# Verify publicKey exists
curl -s -H "Accept: application/activity+json" \
  "https://subaud.io/.ghost/activitypub/users/index" | \
  jq '.publicKey.publicKeyPem'
# Returns: Valid PEM-formatted public key

# Verify dual keys in assertionMethod
curl -s -H "Accept: application/activity+json" \
  "https://subaud.io/.ghost/activitypub/users/index" | \
  jq '.assertionMethod | length'
# Returns: 2 (Ed25519 + RSA)
  1. WebFinger discovery working:
curl "https://subaud.io/.well-known/webfinger?resource=acct:[email protected]"
# Returns valid WebFinger response with ActivityPub links
  1. Dual-key types confirmed:

    • Ed25519 key: Multibase identifier starts with z6Mk
    • RSA key: Longer multibase identifier starts with zggh
  2. Federation capability - The site is discoverable and keys are properly formatted for federation.

Issues

Resolves: #1230

- Use publicKey (singular) and assertionMethods for proper key attachment
- Generate both Ed25519 and RSA keys for broader compatibility
- Handle dual-key JSON storage format
- Maintain backwards compatibility with existing single-key accounts

The previous implementation used publicKeys (plural) which is not a valid
Fedify property, causing federation to fail. This fix ensures keys are
properly attached to the Person object, enabling authentication with
other ActivityPub servers.
@allouis
Copy link
Collaborator

allouis commented Sep 22, 2025

@schuettc I'm not seeing that public keys are not attached to the Person objects 🤔

curl -H "Accept: application/activity+json" \
        "https://activitypub.ghost.org/.ghost/activitypub/users/index" | jq '.publicKey'

You're right that the plural is deprecated by fedify, but it does still work and is correctly attached - is there a specific bug you're running into without these changes? If we can get replication steps that would be awesome

await exportJwk(draft.apPublicKey),
),
ap_private_key: draft.apPrivateKey
ap_public_key: (draft as any).dualKeyPairs
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be casting to any here, the draft type should be updated

ap_private_key: draft.apPrivateKey
ap_public_key: (draft as any).dualKeyPairs
? JSON.stringify({
keys: await Promise.all(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love the idea of storing two different data types in the same column here.

I think we need a discussion about either:

  1. Migrating all columns to store multiple keys
  2. Adding new columns for the new keys

private readonly accountRepository: KnexAccountRepository,
private readonly fedifyContextFactory: FedifyContextFactory,
private readonly generateKeyPair: () => Promise<CryptoKeyPair> = generateCryptoKeyPair,
private readonly generateKeyPair: () => Promise<{ publicKey: CryptoKey, privateKey: CryptoKey }[]> = async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method name needs updating to reflect the new return type (singular -> plural).

Why have we stopped using the CryptoKeyPair type here?

Copy link
Collaborator

@allouis allouis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I 100% understand the bug-fix here, this seems to primarily be adding support for the object integrity proofs feature, is that right?

If there is a bug fix here, it would be great to get it identified & merged separately to the feature, as I think it will be a smaller changeset

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suspected incorrect key algorithm on createInternalAccount

2 participants