gotify/server · error · errCannotParseToken

%w: private key must be %d bytes

Error message

%w: private key must be %d bytes

What it means

For the 2-field (private-key) form of an enhanced token, the decoded key bytes must be exactly ed25519.SeedSize (32 bytes). A different decoded length returns errCannotParseToken wrapped with "private key must be 32 bytes", since ed25519 signing requires a 32-byte seed.

Source

Thrown at auth/token.go:128

	if !found {
		return nil, fmt.Errorf("%w: token must start with %s", errCannotParseToken, enhancedTokenPrefix)
	}

	// count number of dots, one dot -> ident then private key, three dots -> ident, public key, challenge then signature
	fields := strings.SplitN(token, ".", 4)
	if len(fields) != 2 && len(fields) != 4 {
		return nil, fmt.Errorf("%w: token must have 2 or 4 fields separated by dots", errCannotParseToken)
	}
	ident := fields[0]
	pkOrPubkeyB64 := fields[1]
	pkOrPubkeyBytesLen := base64.RawURLEncoding.DecodedLen(len(pkOrPubkeyB64))
	pkOrPubkey, err := base64.RawURLEncoding.DecodeString(pkOrPubkeyB64)
	if err != nil {
		return nil, fmt.Errorf("%w: base64 decode failed: %w", errCannotParseToken, err)
	}
	if len(fields) == 2 {
		if pkOrPubkeyBytesLen != ed25519.SeedSize {
			return nil, fmt.Errorf("%w: private key must be %d bytes", errCannotParseToken, ed25519.SeedSize)
		}
		return &EnhancedToken{
			ident:        ident,
			pubOrPrivKey: pkOrPubkey,
		}, nil
	}
	if pkOrPubkeyBytesLen != ed25519.PublicKeySize {
		return nil, fmt.Errorf("%w: public key must be %d bytes", errCannotParseToken, ed25519.PublicKeySize)
	}
	timestampStr := fields[2]
	timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
	if err != nil {
		return nil, fmt.Errorf("%w: timestamp must be an integer: %w", errCannotParseToken, err)
	}
	signatureB64 := fields[3]
	signatureBytesLen := base64.RawURLEncoding.DecodedLen(len(signatureB64))
	if signatureBytesLen != ed25519.SignatureSize {
		return nil, fmt.Errorf("%w: signature must be %d bytes", errCannotParseToken, ed25519.SignatureSize)

View on GitHub (pinned to 14bfc25627)

Solutions

  1. Use the 4-field public-key form if you hold a public key + challenge + signature instead.
  2. Ensure the private segment is the raw 32-byte ed25519 seed encoded as base64url (43 chars).
  3. Regenerate the keypair/token from the client library so encoding and sizes are correct.
  4. Check for truncation — the base64 segment must be exactly 43 characters for a 32-byte seed.

Example fix

// before: wrong-size key
key := ed25519.Sign(nil, seed) // 64-byte signature pasted as key
// after: encode the 32-byte seed
key := base64.RawURLEncoding.EncodeToString(seed) // 32 bytes -> 43 chars
Defensive patterns

Strategy: validation

Validate before calling

seg := strings.Split(strings.TrimPrefix(tok, "gtfy"), ".")[1]
if base64.RawURLEncoding.DecodedLen(len(seg)) != ed25519.SeedSize {
    return errors.New("2-field token key must decode to 32 bytes")
}

Type guard

func isEd25519SeedSegment(seg string) bool {
    return len(seg) == 43 && base64.RawURLEncoding.DecodedLen(len(seg)) == ed25519.SeedSize
}

Try / catch

if _, err := auth.ParseEnhancedToken(raw); err != nil {
    if strings.Contains(err.Error(), "private key must be") {
        return fmt.Errorf("wrong key material for private-key token: %w", err)
    }
}

Prevention

When it happens

Trigger: Parsing a "gtfyident.<key>" token whose base64 field decodes to something other than 32 bytes — e.g. a public key (also 32 bytes is fine, but a signature or wrong material isn't), a raw password, or a hex-encoded key pasted as if base64.

Common situations: Manually constructing tokens with the wrong key material; pasting a public key where a private seed is required with the 2-field form; truncated keys after copy/paste.

Related errors


AI-assisted analysis of gotify/server@14bfc25627 (2026-09-05). Data as JSON: /api/errors/9e628bde2ea66a10. Report an issue: GitHub.