Identity Models
Starfish speaks a single signature suite on the wire: Ed25519 for signing, X25519 for the KEM (key encapsulation). Every cap-cert, request signature, revocation list, append-author signature, and keyring entry is Ed25519/X25519 — there is no on-wire suite discriminator.
Users with an external secp256k1 root (a Nostr nsec / BIP-340 Schnorr
signer, or an EVM wallet signing EIP-191) can still bootstrap a Starfish
identity: the caller signs a fixed challenge with their external signer, and
Starfish HKDF-derives the Ed25519 + X25519 seeds from that signature. The
external root never appears on the wire; the resulting identity is a normal
Ed25519 identity from every verifier's perspective.
See 11. Identity & Key Derivation for the entry points (passphrase, secp256k1 Schnorr bootstrap, and EVM bootstrap).
Why ed25519-only?
- Smaller wire format — no suite discriminator in canonical signed inputs.
- Single primitive surface — one library path to audit, one set of test vectors, no cross-suite delegation rules.
- Bootstrapping from an external root (secp256k1, FIDO, hardware wallet) is a derivation concern, not a wire concern. Adding a new bootstrap source is an identities-package change; the protocol stays unchanged.
Bringing your own secp256k1 root
import {
SECP256K1_BOOTSTRAP_CHALLENGE,
deriveRootIdentityFromSecp256k1Signature,
} from "@drakkar.software/starfish-identities"
// The caller signs SECP256K1_BOOTSTRAP_CHALLENGE (a fixed 32-byte digest)
// with their external BIP-340 Schnorr signer, using deterministic aux_rand=0.
const signature: Uint8Array = await externalSigner.signSchnorr(
SECP256K1_BOOTSTRAP_CHALLENGE,
{ auxRand: new Uint8Array(32) },
)
const identity = await deriveRootIdentityFromSecp256k1Signature({
secpPubHex, // the originating secp256k1 x-only pubkey (BIP-340)
signature, // 64-byte deterministic Schnorr signature
})
// `identity.keys` carries the derived Ed25519 + X25519 keys.
// `identity.bootstrapOrigin = { kind: "secp256k1", pubHex: secpPubHex }`
// records the origin for external systems (Nostr-aware UIs, audit logs).
The function verifies the signature against secpPubHex (catches caller
bugs and makes bootstrapOrigin a verifiable claim), then HKDF-SHA256
expands the 64-byte signature into the Ed25519 signing seed and the X25519
KEM seed under domain-separated info strings.
Determinism contract: the external signer MUST use deterministic
Schnorr (aux_rand = 0). A signer that injects randomness yields a
different identity on every call. Most Schnorr libraries accept an
aux_rand argument; pass new Uint8Array(32) (TS) / b"\x00" * 32
(Python).
Bringing your own EVM wallet
import {
EVM_BOOTSTRAP_CHALLENGE,
deriveRootIdentityFromEvmSignature,
} from "@drakkar.software/starfish-identities"
// The wallet signs EVM_BOOTSTRAP_CHALLENGE (a fixed message) with EIP-191
// personal_sign. Standard EVM signers use deterministic ECDSA (RFC 6979).
const signature: Uint8Array = await wallet.personalSign(EVM_BOOTSTRAP_CHALLENGE)
const identity = await deriveRootIdentityFromEvmSignature({
address, // the originating EVM address (0x-prefixed, 40 hex)
signature, // 65-byte r‖s‖v ECDSA signature
})
// `identity.bootstrapOrigin = { kind: "evm", address }`.
The function recovers the signer from the EIP-191 digest and checks it equals
address (catches caller bugs; makes bootstrapOrigin a verifiable claim),
then HKDF-expands the 65-byte signature into the Ed25519 + X25519 seeds under an
EVM-specific salt. EVM_BOOTSTRAP_CHALLENGE defaults to "starfish:bootstrap-evm";
pass a custom challenge to namespace an app's identities (a distinct challenge →
a distinct userId from the same wallet). Determinism contract: the wallet
MUST sign with deterministic ECDSA (RFC 6979) — the default for standard EVM
signers; EIP-191 carries no per-call salt.
What bootstrapOrigin is and isn't
- Is: a non-load-bearing metadata field on
RootIdentityrecording the originating root —{ kind: "secp256k1", pubHex }or{ kind: "evm", address }. Useful for displaying "this Starfish identity is bootstrapped from npub1…" or "…from 0x…" in a UI, or recording it in an audit log. - Isn't: signed, transmitted with caps or requests, or load-bearing for any verification. The Ed25519 identity stands on its own.
Cross-language parity
Both bootstrap derivations are byte-identical across TypeScript and Python —
locked by tests/test-vectors/identity-derivation-secp256k1.json and
tests/test-vectors/identity-derivation-evm.json. The same external signature
produces the same Ed25519 + X25519 keys (and therefore the same userId) in
both runtimes.