Skip to content

Instantly share code, notes, and snippets.

@lizthegrey
Last active January 5, 2026 00:02
Show Gist options
  • Select an option

  • Save lizthegrey/085c20fc62f08b2ab1905fb52d5c7197 to your computer and use it in GitHub Desktop.

Select an option

Save lizthegrey/085c20fc62f08b2ab1905fb52d5c7197 to your computer and use it in GitHub Desktop.

Hockeypuck v5/v6 Key Support Investigation

Date: 2026-01-02 Investigators: Liz Fong-Jones & Claude Status: BLOCKED - Upstream go-crypto dependency issues

Executive Summary

Initial investigation found that Hockeypuck has most v5/v6 infrastructure in place. However, deep testing revealed the blocker is NOT in Hockeypuck, but in the go-crypto library dependency:

  1. Ed448 compression flag 0xA5 rejected - go-crypto only accepts 0x40, but GnuPG 2.5 v5 keys use 0xA5
  2. Kyber algorithm (type 8) not defined - go-crypto has no constant or parser for Kyber post-quantum encryption

These are upstream issues that must be fixed in ProtonMail/go-crypto or pgpkeys-eu/go-crypto fork.


Background

Liz generated a new ed448/kyber OpenPGP v5 key using GnuPG 2.5 (LibrePGP) for post-quantum cryptography. Discovered that current keyservers reject v5 keys:

  • keys.openpgp.org: "OpenPGP v3 and OpenPGP v5 (LibrePGP) are not supported"
  • Sequoia-PGP (Hagrid backend) cannot even read v5 keys
  • Ubuntu's keyserver.ubuntu.com runs Hockeypuck, which also doesn't support v5/v6 yet

Current Situation

  • Need: PPA work requires keys on keyserver.ubuntu.com
  • Workaround: Using old v4 DSA/RSA key (valid until 2030) for PPA uploads
  • Goal: Get v5 key support into Hockeypuck so it can be uploaded to Ubuntu keyservers

GnuPG/IETF Standards Split

  • LibrePGP (GnuPG 2.5): Uses v5 format per RFC4880bis
  • IETF RFC9580: Rejected v5, defined v6 instead
  • GnuPG is "obstinately rejecting RFC9580" - no v6 support planned
  • Good news: Hockeypuck maintainer intends to support BOTH v5 and v6

Hockeypuck Issue #247 Status

Dependencies (COMPLETE)

  • ✅ #252: Storage preening thread (closed July 2025)
  • ✅ #285: Purge string reversals from codebase (closed Dec 2025)

Milestone 2.4

  • Due date: Oct 31, 2025 (MISSED)
  • Status: 9 open issues, including #247
  • Description: "Target the forthcoming HKP RFC, including v6 certificate support"

CRITICAL BLOCKER: go-crypto Algorithm Support (2026-01-02)

Testing Revealed Root Cause

After adding Liz's real v5 key as a test fixture and fixing Hockeypuck's setKeyID() function, attempted to run integration tests. Tests failed with errors from go-crypto:

Error 1: unsupported EdDSA compression: 165 (0xA5)
Error 2: public key type: 8

Issue #1: Ed448 Compression Flag 0xA5

What GnuPG 2.5 generates:

  • v5 keys use algorithm 22 (EdDSALegacy), NOT algorithm 28 (Ed448)
  • Ed448 curve: OID 1.3.101.113
  • Point data (456 bits / 57 bytes) starts with byte 0xA5

What go-crypto rejects:

// vendor/.../openpgp/packet/public_key.go:531-539
switch flag := pk.p.Bytes()[0]; flag {
case 0x04:
    return errors.UnsupportedError(...)
case 0x40:
    err = pub.UnmarshalPoint(pk.p.Bytes())  // ONLY THIS
default:
    return errors.UnsupportedError(...)  // REJECTS 0xA5!
}

What libgcrypt (GnuPG's crypto library) accepts:

// libgcrypt/cipher/ecc-eddsa.c _gcry_ecc_eddsa_ensure_compact()
if (buf[0] == 0x04) {
    // Decompress SEC1 uncompressed
} else if (buf[0] == 0x40) {
    // Strip 0x40 prefix
} else {
    return 0;  // ✅ ACCEPT ANYTHING ELSE (including 0xA5)
}

Conclusion: go-crypto is overly restrictive compared to GnuPG's libgcrypt.

Issue #2: Kyber Algorithm Missing

v5 subkey uses: Algorithm 8 (Kyber post-quantum encryption)

go-crypto constants (packet.go:525-543):

const (
    PubKeyAlgoECDH    PublicKeyAlgorithm = 18
    PubKeyAlgoECDSA   PublicKeyAlgorithm = 19
    PubKeyAlgoEdDSA   PublicKeyAlgorithm = 22  // What v5 keys use
    PubKeyAlgoX25519  PublicKeyAlgorithm = 25
    PubKeyAlgoX448    PublicKeyAlgorithm = 26
    PubKeyAlgoEd25519 PublicKeyAlgorithm = 27
    PubKeyAlgoEd448   PublicKeyAlgorithm = 28
    // ❌ NO ALGORITHM 8 (Kyber)
)

Result: When parser encounters algorithm 8, it returns unsupported error: public key type: 8

Evidence: Packet Analysis

GnuPG's view of the key:

$ gpg --list-packets testing/data/v5_ed448_kyber.asc
:public key packet:
	version 5, algo 22, created 1767336170
	pkey[0]: [32 bits] ed448 (1.3.101.113)
	pkey[1]: [456 bits]
	keyid: 8CC84EAD81F6030A

Raw bytes:

98 49 05 69 57 68 ea 16 00 00 00 3f 03 2b 65 71 01 c8 a5 c7 3b...
      ^v5     ^algo22         ^OID         ^MPI  ^0xA5

Test results with -tags v5:

V5Disabled = false

Packet 1: *packet.UnsupportedPacket
  UNSUPPORTED: unsupported EdDSA compression: 165
  (Was PublicKey version 5)

Packet 14: *packet.UnsupportedPacket
  UNSUPPORTED: public key type: 8
  (Was PublicKey version 5)

Required Fixes (go-crypto)

Fix #1: Relax parseEdDSA() compression check

  • Change line 538 from rejecting unknown flags to accepting them (match libgcrypt)
  • Allow 0xA5 and other encodings used by GnuPG 2.5

Fix #2: Add Kyber algorithm support

  • Add PubKeyAlgoKyber PublicKeyAlgorithm = 8 constant
  • Implement parseKyber() function (or at minimum: consume and skip gracefully)

Detailed Fix Proposals

Fix #1: Relax parseEdDSA() Compression Check

File: vendor/github.com/ProtonMail/go-crypto/openpgp/packet/public_key.go Lines: 531-539

Current code:

switch flag := pk.p.Bytes()[0]; flag {
case 0x04:
    return errors.UnsupportedError("unsupported EdDSA compression: " + strconv.Itoa(int(flag)))
case 0x40:
    err = pub.UnmarshalPoint(pk.p.Bytes())
default:
    return errors.UnsupportedError("unsupported EdDSA compression: " + strconv.Itoa(int(flag)))
}

Proposed fix (match libgcrypt behavior):

switch flag := pk.p.Bytes()[0]; flag {
case 0x04:
    // TODO: Implement SEC1 uncompressed decompression
    return errors.UnsupportedError("unsupported EdDSA compression: " + strconv.Itoa(int(flag)))
case 0x40:
    // Strip 0x40 prefix byte (legacy format)
    err = pub.UnmarshalPoint(pk.p.Bytes())
default:
    // Assume already in compact native format (libgcrypt compatibility)
    // This handles 0xA5 and other encodings used by GnuPG 2.5 v5 keys
    err = pub.UnmarshalPoint(pk.p.Bytes())
}

Rationale:

  • Matches libgcrypt's permissive behavior
  • Allows future encoding formats without code changes
  • Only rejects 0x04 (truly unsupported uncompressed format)

Fix #2: Add Kyber Algorithm Support

File: vendor/github.com/ProtonMail/go-crypto/openpgp/packet/packet.go After line 538:

const (
    // ... existing constants ...
    PubKeyAlgoEd448   PublicKeyAlgorithm = 28
    PubKeyAlgoKyber   PublicKeyAlgorithm = 8  // ML-KEM (Kyber) post-quantum
)

File: vendor/github.com/ProtonMail/go-crypto/openpgp/packet/public_key.go Add case in parse() switch:

case PubKeyAlgoKyber:
    err = pk.parseKyber(r)

Option A - Parse as opaque data:

func (pk *PublicKey) parseKyber(r io.Reader) (err error) {
    // Read key material as opaque data (proper Kyber support requires ML-KEM library)
    var buf [2]byte
    if _, err = io.ReadFull(r, buf[:]); err != nil {
        return
    }
    length := int(buf[0])<<8 | int(buf[1])
    keyData := make([]byte, length)
    if _, err = io.ReadFull(r, keyData); err != nil {
        return
    }
    pk.PublicKey = keyData
    return nil
}

Option B - Skip gracefully (simpler):

case PubKeyAlgoKyber:
    // Consume and skip Kyber subkey (not yet supported)
    // Don't fail - allows primary key to be parsed
    _, err = io.Copy(io.Discard, r)

Path Forward

Immediate steps:

  1. Check ProtonMail/go-crypto main branch for existing fixes
  2. Apply local patch to test the fix works
  3. Submit upstream PR to pgpkeys-eu/go-crypto fork (used by Hockeypuck)
  4. Update Hockeypuck's go.mod after go-crypto is patched

Options for contribution:

  • Option A: Local patch in Hockeypuck vendor/ (fastest, but diverges from upstream)
  • Option B: PR to pgpkeys-eu/go-crypto (recommended - benefits Hockeypuck ecosystem)
  • Option C: PR to ProtonMail/go-crypto (slowest, but broadest impact)

See: /home/lizf/.claude/plans/zany-crafting-petal.md for complete implementation plan


Current Implementation Status

PARTIAL IMPLEMENTATION EXISTS:

  1. Dependencies ready:

    • ProtonMail/go-crypto v1.3 vendored (replaced with pgpkeys-eu/go-crypto fork from 2025-11-26)
    • go-crypto has UpgradeToV5() and UpgradeToV6() functions
  2. Code evidence of v5/v6 awareness:

    // src/hockeypuck/openpgp/signature.go
    // Since v5 keys are permitted to make v4 sigs, we infer IssuerFpVersion==5 by heuristic.
    if len(s.IssuerFingerprint) == 32 && s.Version != 6 {
        sig.IssuerFpVersion = 5
    }
    // src/hockeypuck/openpgp/io.go
    // Write detached redacting revocations first (except for v6 keys)
    if node.Version < 6 {
        revoc, err := node.RedactingSignature()
        // ...
    }
  3. Data structures ready:

    • PublicKey.Version field exists (uint8)
    • Populated from packet.PublicKey.Version
    • IssuerFpVersion field exists in Signature struct

BLOCKERS REMAINING:

  1. 🔴 CRITICAL: go-crypto algorithm support (UPSTREAM DEPENDENCY)

    • Ed448 compression flag 0xA5 rejected
    • Kyber algorithm (type 8) not defined
    • BLOCKS ALL v5 KEY PARSING
  2. 🟡 MEDIUM: Hockeypuck setKeyID() fix (DONE ✅)

    • Fixed to handle v5/v6 64-character fingerprints
    • Uses first 16 hex chars for key ID (v5/v6 spec)
    • File: openpgp/pubkey.go
  3. 🟢 LOW: SKS recon protocol not updated (CAN DEFER)

    // src/hockeypuck/hkp/sks/recon.go
    "versions:34",  // no v5 or 6 yet
    • Only affects synchronization with other keyservers
    • Local storage/retrieval can work without this
    • Can be updated after ecosystem coordination

Maintainer Comments (from #247)

  • andrewgdotcom: "This will be a big job, and I won't have the capacity to do it myself"
  • Intent to support both v5 and v6 keys
  • "Many commonalities between v5 and v6 keys so it won't be twice the work"
  • Dependent on go-crypto v2 for v6 keys (see PR ProtonMail/go-crypto#182)
  • Also dependent on HKP RFC work (#252, #285)

Recent Activity

  • Dec 2025: Issue #285 (string reversals) completed
  • Nov 2025: go-crypto fork updated (pgpkeys-eu/go-crypto)
  • Milestone 2.4 due date passed without completion

Work Completed (2026-01-02)

Hockeypuck Code Changes ✅

  1. Fixed setKeyID() for v5/v6 - openpgp/pubkey.go:191-201

    • Now correctly extracts first 16 hex chars for v5/v6 keys
    • Validates fingerprint length (64 chars for v5/v6)
  2. Added test fixture - testing/data/v5_ed448_kyber.asc

    • Liz's real v5 ed448/kyber key
    • Fingerprint: 8CC84EAD81F6030A3DAD7682E1A1B1460361985E971CE686465E13793CF804B3
  3. Created integration test - openpgp/io_test.go

    • TestV5Ed448Key test case
    • Currently fails due to go-crypto blockers

Documentation Created ✅

  1. V5_KEY_PARSING_BLOCKERS.md - Detailed root cause analysis
  2. Updated plan - /home/lizf/.claude/plans/zany-crafting-petal.md
  3. This file - Investigation notes with blocker section

go-crypto Fixes Implemented (2026-01-03)

Status: ✅ WORKING - v5 keys now parse successfully

Changes Made

Fix #1: Ed448 Compression Flag 0xA5 Support

File: vendor/github.com/ProtonMail/go-crypto/openpgp/packet/public_key.go (lines 529-554)

Problem: v5 Ed448 keys use algorithm 22 (EdDSALegacy) with native point encoding. The first byte is 0xA5 (actual key data) rather than 0x40 (compression flag). When the default case tried to call UnmarshalPoint, it failed because Ed448's UnmarshalBytePoint expects 58 bytes (57 + 1-byte prefix) but v5 keys have exactly 57 bytes.

Solution implemented:

switch flag := pk.p.Bytes()[0]; flag {
case 0x04:
    return errors.UnsupportedError("unsupported EdDSA compression: " + strconv.Itoa(int(flag)))
case 0x40:
    // v6 format with 0x40 prefix - UnmarshalPoint will strip it
    err = pub.UnmarshalPoint(pk.p.Bytes())
default:
    // Already in compact native format (libgcrypt compatibility)
    // For EdDSALegacy (algo 22), the point is in native format without prefix
    // Set X directly instead of calling UnmarshalPoint to avoid length check
    pub.X = pk.p.Bytes()
    if pub.X == nil || len(pub.X) == 0 {
        return errors.StructuralError("empty EdDSA public key point")
    }
}

Key insight: Setting pub.X directly bypasses UnmarshalBytePoint's length validation, which expected 58 bytes but v5 keys have 57 bytes with 0xA5 as actual data, not a prefix.

Fix #2: Kyber Algorithm Support

File: vendor/github.com/ProtonMail/go-crypto/openpgp/packet/packet.go (line 527)

Added constant:

PubKeyAlgoKyber PublicKeyAlgorithm = 8  // ML-KEM (Kyber) post-quantum - GnuPG 2.5

File: vendor/github.com/ProtonMail/go-crypto/openpgp/packet/public_key.go (lines 609-651)

Added KyberPublicKey struct:

type KyberPublicKey struct {
    // OID identifies the composite algorithm (e.g., ky1024_cv448 = 1.3.101.111)
    oid []byte
    // ECC point (X25519 or X448) for classical ECDH
    eccPoint []byte
    // ML-KEM public key material (ML-KEM-768 or ML-KEM-1024)
    mlkemKey []byte
}

Added parser that properly parses the composite structure:

func (pk *PublicKey) parseKyber(r io.Reader) (err error) {
    // Kyber (ML-KEM) support is experimental in GnuPG 2.5
    // Parse the composite structure: OID + ECC point + ML-KEM key
    // Note: Encryption/decryption operations not yet implemented

    // Read OID identifying the composite algorithm
    pk.oid = new(encoding.OID)
    if _, err = pk.oid.ReadFrom(r); err != nil {
        return
    }

    // Read ECC point (X25519 or X448)
    eccMPI := new(encoding.MPI)
    if _, err = eccMPI.ReadFrom(r); err != nil {
        return
    }

    // Read ML-KEM public key material
    mlkemMPI := new(encoding.MPI)
    if _, err = mlkemMPI.ReadFrom(r); err != nil {
        return
    }

    // Store parsed components
    kyberKey := &KyberPublicKey{
        oid:      pk.oid.Bytes(),
        eccPoint: eccMPI.Bytes(),
        mlkemKey: mlkemMPI.Bytes(),
    }
    pk.PublicKey = kyberKey
    return nil
}

Added serialization and utility support:

  • Case in parse() switch (lines 283-286)
  • Case in publicKeyMaterialLength() (lines 740-748) - properly encodes all three components
  • Case in serializeWithoutHeaders() (lines 841-856) - serializes OID + ECC point + ML-KEM key
  • Case in BitLength() (lines 1169-1172) - returns ML-KEM key size in bits
  • Updated CanSign() (line 819) - Kyber is encryption-only, cannot sign

Test Results

Test case: openpgp/io_test.go:339-351 (TestV5Ed448Key)

func (s *SamplePacketSuite) TestV5Ed448Key(c *gc.C) {
    key := MustInputAscKey("v5_ed448_kyber.asc")
    c.Assert(key, gc.NotNil)
    c.Assert(key.Version, gc.Equals, uint8(5))
    // v5 keys use SHA-256 fingerprints (64 hex chars)
    c.Assert(len(key.Fingerprint), gc.Equals, 64)
    c.Assert(key.Fingerprint, gc.Equals, "8cc84ead81f6030a3dad7682e1a1b1460361985e971ce686465e13793cf804b3")
    // v5 key ID is first 8 bytes (16 hex chars) of fingerprint
    c.Assert(key.KeyID, gc.Equals, "8cc84ead81f6030a")
    // Check that we have user IDs and subkeys
    c.Assert(len(key.UserIDs), gc.Equals, 3)  // 3 text UIDs (photo stored separately as UserAttribute)
    c.Assert(len(key.SubKeys), gc.Equals, 1)  // kyber encryption subkey
}

Status: ✅ PASS - Full test suite: OK: 37 passed

Parsed successfully:

  • Ed448 primary key (algorithm 22, version 5)
  • Kyber subkey (algorithm 8)
  • All 15 packets from v5_ed448_kyber.asc
  • Fingerprint: 8CC84EAD81F6030A3DAD7682E1A1B1460361985E971CE686465E13793CF804B3
  • KeyID: 8cc84ead81f6030a (first 16 hex chars of fingerprint)
  • 3 UserIDs (photo attribute stored separately)
  • 1 Kyber subkey

Known Limitations

  1. Kyber encryption/decryption not implemented - The composite structure (OID + ECC point + ML-KEM key) is properly parsed and stored in a KyberPublicKey struct, but actual encryption/decryption operations are not implemented. Full ML-KEM cryptographic operations require additional library support (e.g., FiloSottile/mlkem768).
  2. v5 format is experimental - GnuPG 2.5 v5 support is beta software. The format may change.

Backward Compatibility

These changes maintain compatibility with existing keys:

  • v6 Ed448 keys with 0x40 prefix continue to work via UnmarshalPoint
  • v4 keys unaffected
  • Kyber support is additive (no existing code paths modified)

Pull Requests Created (2026-01-03)

✅ go-crypto PR: pgpkeys-eu/go-crypto#8

URL: pgpkeys-eu/go-crypto#8 Status: Awaiting review Branch: lizthegrey:lizf.v5-kyber-support

Changes submitted:

  1. Modified parseEdDSA() to handle native Ed448 encoding (0xA5 flag)
  2. Added PubKeyAlgoKyber constant (value 8)
  3. Added KyberPublicKey struct to represent composite ML-KEM + ECC keys
  4. Added parseKyber() function that properly parses OID + ECC point + ML-KEM key
  5. Added Kyber cases to: parse(), publicKeyMaterialLength(), serializeWithoutHeaders(), BitLength()
  6. Updated CanSign() to exclude Kyber (encryption-only algorithm)

Testing: All go-crypto tests pass (go test -tags v5 ./openpgp/...)

Files modified:

  • openpgp/packet/public_key.go (+87 lines)
  • openpgp/packet/packet.go (+1 line)

Note: Submitted to pgpkeys-eu/go-crypto (Hockeypuck's fork) as interim solution. Once proven stable in Hockeypuck production, can be forwarded to ProtonMail/go-crypto upstream.

✅ Hockeypuck PR: hockeypuck/hockeypuck#429

URL: hockeypuck/hockeypuck#429 Status: Awaiting go-crypto PR merge Branch: lizthegrey:lizf.v5-key-support

Changes submitted:

  • Fixed setKeyID() to handle v5/v6 64-character fingerprints
  • Added TestV5Ed448Key test case
  • Added testing/data/v5_ed448_kyber.asc test fixture (Liz's real v5 key)

Testing: All 37 Hockeypuck tests pass

Files modified:

  • src/hockeypuck/openpgp/pubkey.go (setKeyID fix)
  • src/hockeypuck/openpgp/io_test.go (test case)
  • src/hockeypuck/testing/data/v5_ed448_kyber.asc (new fixture)

Dependency: Requires go-crypto PR to merge first, then Hockeypuck can update go.mod

✅ Issue Comment Posted

URL: hockeypuck/hockeypuck#247 (comment)

Updated issue #247 with status, links to both PRs, and summary of findings.


CRITICAL UPDATE: v5/Kyber Work Abandoned (2026-01-04)

Political Context - The Work Is Not Salvageable

After PR feedback from Andrew (@andrewg.com) and discussions with hko-s (@hko-s.bsky.social), the v5/Kyber support work is a dead end:

  1. v5 keys are politically dead - LibrePGP vs IETF schism means v5 format will not be widely adopted
  2. Kyber (algorithm 8) won't be merged - GnuPG's implementation diverged from IETF ML-KEM spec in "spectacular bad faith"
  3. v6 format is the future - RFC 9580 has 6+ interoperable implementations
  4. Keyserver ecosystem issues - Even if merged, Ubuntu PPAs use sq (Sequoia) which doesn't support v5

Andrew's comment on PR #8:

"EdDSA changes appear to be a workaround for GnuPG's violation of its own (LibrePGP) specification"

"I think it would be more reliable to detect the novel format by length rather than the prefix byte"

"Did you (or claude?) find a source for the offending format change btw?"

The ONLY salvageable piece: Finding the libgcrypt source showing Ed448 encoding behavior

libgcrypt Source Analysis - The Root Cause

Finding: GnuPG's libgcrypt produces Ed448 public keys WITHOUT the 0x40 prefix byte, violating OpenPGP specification expectations.

Source Location

  • libgcrypt version: 1.11.2
  • Path: /home/lizf/gpg-2.5/libgcrypt20-1.11.2/
  • Key files:
    • cipher/ecc-eddsa.c - EdDSA encoding/decoding functions
    • cipher/ecc.c - Key generation calling code
    • cipher/ecc-curves.c - Curve definitions

What libgcrypt PRODUCES (encoding)

1. Ed448 uses SAFECURVE dialect (cipher/ecc-curves.c:182-183):

"Ed448", 448, 1,
MPI_EC_EDWARDS, ECC_DIALECT_SAFECURVE,

2. Key generation encoding logic (cipher/ecc.c:775-778):

rc = _gcry_ecc_eddsa_encodepoint (ec->Q, ec, Gx, Gy,
                                  (ec->dialect != ECC_DIALECT_SAFECURVE
                                   && !!(flags & PUBKEY_FLAG_COMP)),
                                  &encpk, &encpklen);

→ The with_prefix parameter = (ec->dialect != ECC_DIALECT_SAFECURVE && !!(flags & PUBKEY_FLAG_COMP)) → For Ed448 (SAFECURVE): with_prefix = 0 (FALSE)

3. Encoding function behavior (cipher/ecc-eddsa.c:92-112 in eddsa_encode_x_y):

int off = with_prefix? 1:0;
// ...
if (off)
    rawmpi[0] = 0x40;  // Only adds 0x40 if with_prefix=1
// ...
*r_buflen = rawmpilen + off;

→ When with_prefix=0: NO 0x40 prefix added, length = 57 bytes

RESULT: libgcrypt produces 57-byte Ed448 public keys with NO 0x40 prefix

What libgcrypt ACCEPTS (decoding)

Decoding function (cipher/ecc-eddsa.c:372-489 in _gcry_ecc_eddsa_decodepoint):

Lines 404-435 - Handle SEC1 uncompressed (0x04):

if (rawmpilen > 1 && (rawmpilen%2))
{
    if (buf[0] == 0x04) {
        // Extract x and y, compress to EdDSA format
    }
}

Lines 437-444 - Handle 0x40 prefix (libgcrypt extension):

/* Check whether the public key has been prefixed with a 0x40
   byte to explicitly indicate compressed format using a SEC1
   alike prefix byte.  This is a Libgcrypt extension.  */
if (buf[0] == 0x40)
{
    rawmpilen--;
    buf++;  // Strip the prefix
}

Lines 447-452 - Accept everything else as-is:

reverse_buffer (buf, b);  /* Process as little-endian */

RESULT: libgcrypt accepts BOTH:

  • 57-byte unprefixed format (what it produces for SAFECURVE/Ed448)
  • 58-byte 0x40-prefixed format (strips prefix, compatible with v6)
  • Any other first byte values (treats entire buffer as native EdDSA point)

The Discrepancy

  1. OpenPGP v6 specification (RFC 9580): Requires 0x40 prefix for EdDSA points (58 bytes for Ed448)

  2. libgcrypt behavior: Produces 57-byte unprefixed Ed448 points because Ed448 uses ECC_DIALECT_SAFECURVE

  3. The mismatch:

    • GnuPG 2.5 v5 keys use libgcrypt's native 57-byte format (no prefix)
    • OpenPGP v6 expects 58-byte format (0x40 + 57 bytes)
    • libgcrypt's decoder is permissive and accepts both formats
    • Other OpenPGP implementations expect strict compliance with the spec

Recommended Solutions

Option 1: Fix libgcrypt/GnuPG (Upstream) Change Ed448 encoding to always add 0x40 prefix for OpenPGP keys.

  • Pros: Fixes root cause, makes GnuPG spec-compliant
  • Cons: Requires GnuPG cooperation, backward compatibility issues

Option 2: Length-Based Detection in go-crypto (Andrew's suggestion) Detect format by length rather than prefix byte:

  • 57 bytes → Treat as unprefixed Ed448 (libgcrypt native format)
  • 58 bytes starting with 0x40 → Strip prefix and process
  • 58 bytes starting with other → Error (invalid)
  • Pros: Pragmatic workaround, backward compatible, explicit
  • Cons: Doesn't fix the root cause, accepts non-compliant keys

Option 3: Hybrid Approach Accept length-based detection in go-crypto BUT file bug report with GnuPG.

  • Most realistic given the political situation

Status: Investigation Complete

The libgcrypt source code evidence is documented. Andrew and hko-s can now decide whether to:

  1. Push for upstream GnuPG fix
  2. Accept length-based detection in go-crypto
  3. Hybrid approach (workaround + upstream bug report)

All v5/Kyber-specific work should be abandoned. Only the Ed448 encoding fix is worth pursuing.


Next Steps (REVISED 2026-01-04)

1. Wait for go-crypto PR review

  • pgpkeys-eu maintainer to review and merge PR #8
  • Address any feedback or changes requested

2. Update Hockeypuck go.mod

  • After go-crypto PR merges, update Hockeypuck's go.mod to reference new commit
  • This can be done by the maintainer or as a follow-up commit to PR #429

3. Merge Hockeypuck PR

  • Once go-crypto dependency is updated, PR #429 can be merged
  • v5 key support will be complete end-to-end

4. Test in production

  • Upload Liz's v5 key to Hockeypuck instance
  • Verify storage, retrieval, and SKS recon (after updating "versions:34")

Key Files to Review

  • src/hockeypuck/openpgp/pubkey.go - Key parsing/storage
  • src/hockeypuck/openpgp/signature.go - Already has v5 handling
  • src/hockeypuck/openpgp/io.go - Already has v6 awareness
  • src/hockeypuck/hkp/sks/recon.go - SKS sync protocol ("versions:34")
  • go.mod - Dependencies (go-crypto v1.3 via pgpkeys-eu fork)

Links

References

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