Cryptographic Identity
Every actor in the Muse ecosystem — human, agent, or organisation — has a cryptographic identity derived from a single HD wallet seed using SLIP-0010. There are no passwords, no JWTs, no OAuth flows. Every HTTP request is signed with Ed25519 and verified at the hub before any data is touched. In addition to humans, agents and organizations are first-class citizens with their own derived keys, and every commit carries a tamper-evident provenance chain traceable back to a root mnemonic.
Key model
Muse uses Ed25519 (RFC 8032) throughout. Every human identity is backed by a BIP-39 mnemonic — the root of an HD wallet. Child keys are derived deterministically using SLIP-0010 (the standard for non-secp256k1 curves), so an agent spawned from a human inherits a key derived from that human's seed at a specific HD path. You can rotate, recover, and audit the entire chain from one secret.
Derivation path
Muse's HD path has six levels. Every level answers exactly one question:
m / purpose' / domain' / entity_type' / entity_id' / role' / index'
The purpose' level is 1075233755' —
int.from_bytes(sha256(b"muse")[:4], "big") & 0x7FFFFFFF —
a hardened namespace that separates Muse keys from every other HD wallet
application at the root. All six levels use hardened derivation
('), so no child key ever exposes its siblings or parent.
muse auth show --json prints your full HD identity — path decoded,
public key, fingerprint, and hub registration timestamp:
muse auth show --json
{
"hub": "staging.musehub.ai",
"handle": "gabriel",
"type": "human",
"algorithm": "ed25519",
"fingerprint": "sha256:a3f2c9d8e1b47f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
"hd_path": "m/1075233755'/1660078172'/0'/0'/0'/0'",
"mnemonic_word_count": 24,
"derived_paths": {
"identity_msign": "m/1075233755'/1660078172'/0'/0'/0'/0'",
"payments_mpay": "m/1075233755'/284229149'/0'/0'/0'/0'",
"avax_c_chain": "m/44'/60'/0'/0/0",
"agent_slot_0": "m/1075233755'/1660078172'/1'/0'/0'/0'"
},
"avax_c_chain_address": "0x3f8a1c2d…"
}
Domain namespace
The domain' level is the atom. Everything else — entities, keys,
roles — exists within a domain. The namespace is open by design: new domains
plug in without touching the schema.
| Index | Domain | Use |
|---|---|---|
1660078172' | Identity | MSign auth, MuseHub registration, cross-domain self |
284229149' | Payments | MPay claims, financial settlement |
678195575' | Code | Commit provenance, software VCS |
915186137' | Mist | Content-addressed artifact hosting |
1755707987' | Music | Stori, audio production signing |
1444628350' | MIDI | Maestro, symbolic music |
1658731548' | Prose | Documents, markdown, long-form |
1556829714' | Blockchain | On-chain operations — ERC8004, EVM, AVAX |
2023564266' | Generic | Untyped / catch-all (reserved) |
… | (open) | Future domains extend the table, not the schema |
Domain 1660078172' (Identity) is the special case — it is the key
that crosses all other domains. When Gabriel logs into MuseHub, that is
domain=1660078172'. When Gabriel signs a commit, that is
domain=678195575'. Same entity, different domain keys, provably isolated.
Domain integers are computed as
int.from_bytes(sha256(name.encode())[:4], "big") & 0x7FFFFFFF —
a stable, collision-resistant value derived from the canonical domain name
string. Use muse domain index <name> to compute or look up
any domain integer offline.
Principle of least privilege — encoded in the key tree
Because domain is a structural level in the derivation path, agent delegation
is cryptographically scoped, not policy-scoped. An agent sub-seed derived at
domain=284229149' can only produce keys within the Payments domain. It
physically cannot derive an Identity key or a Code key — not because a rule
forbids it, but because the math does not connect those branches.
The three entity types map directly onto the derivation path:
| Index | Entity type | Description |
|---|---|---|
0' | Human | A person with a root mnemonic. Account 0 is always the primary identity. |
1' | Agent | Any non-human principal delegated from a human or org — LLM agents, CI runners, daemons. All the same at the key level. |
2' | Org | A collective identity. Membership and governance live above the key layer; the tree records only that this principal is a collective. |
There is no separate service or daemon type — those are agents. Quorum is not an entity type either; it is a threshold policy that references multiple paths at verification time. The key tree stays flat and complete.
# Music composition agent — can only derive Music domain keys
music_agent_seed = derive_sub_seed(master_seed, domain=1755707987, entity_type=1, agent_id=0)
# Code review agent — can only derive Code domain keys
code_agent_seed = derive_sub_seed(master_seed, domain=678195575, entity_type=1, agent_id=0)
# Agentception orchestrator — Identity domain only, nothing else
auth_agent_seed = derive_sub_seed(master_seed, domain=1660078172, entity_type=1, agent_id=0)
The mnemonic lives in ~/.muse/identity.toml (in memory only —
never written to disk after initial setup). Each derived key is persisted as a
PEM file alongside its fingerprint. Agentception injects agent sub-seeds into
spawned agents via the MUSE_AGENT_KEY_FD file descriptor — a 64-byte
raw sub-seed passed over a pipe — so the agent's process never touches the
root mnemonic.
ml-dsa-65 (CRYSTALS-Dilithium) is reserved for post-quantum
migration. The algorithm prefix in every ID and signature string
(ed25519:…, mldsa65:…) means the format is
self-describing — old and new keys coexist without a flag day.
MSign authentication
Every authenticated request to MuseHub carries an MSign Authorization header. No session cookies, no bearer tokens. The hub verifies the signature on every request — stateless, replay-protected, and auditable.
Authorization: MSign handle="gabriel" alg="ed25519" ts=1744000000 sig="<base64url>"
What gets signed
The signature covers a canonical message assembled from the request's algorithm, method, host, path, timestamp, and body hash — bound together with newlines:
{algorithm}
{METHOD}
{host}
{path_with_query}
{unix_timestamp}
{hex(sha256(request_body))}
ed25519
POST
staging.musehub.ai
/gabriel/muse/push
1744000000
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
| Field | Value | Notes |
|---|---|---|
| algorithm | ed25519 | Matches alg= in header |
| METHOD | POST, GET, … | Uppercase |
| host | staging.musehub.ai | Lowercase; strip standard ports (443/80) |
| path_with_query | /gabriel/muse/push | Include ?query if present |
| unix_timestamp | 1744000000 | Seconds since epoch; replay window ±30 s |
| body_hash | 64-char lowercase hex | SHA-256 of raw request body; empty body → SHA-256 of b"" |
Signing a request
# Produce an Authorization header for a POST
muse sign header \
--method POST \
--path /gabriel/muse/push \
--hub http://staging.musehub.ai \
--json
# Sign and execute in one command
muse sign request \
--method POST \
--url http://staging.musehub.ai/gabriel/muse/push \
--body-file payload.msgpack \
--json
# Verify a header (useful in tests)
muse sign verify \
--header 'MSign handle="gabriel" alg="ed25519" ts=1744000000 sig="..."' \
--method POST \
--url http://staging.musehub.ai/gabriel/muse/push \
--public-key-b64 <base64url-pubkey>
from muse.core.msign import sign_request, verify_request
import httpx, time
# sign_request returns the Authorization header value
auth_header = sign_request(
method="POST",
url="http://staging.musehub.ai/gabriel/muse/push",
body=payload_bytes,
private_key=ed25519_private_key, # cryptography.hazmat PrivateKey
handle="gabriel",
)
resp = httpx.post(
"http://staging.musehub.ai/gabriel/muse/push",
content=payload_bytes,
headers={"Authorization": auth_header,
"Content-Type": "application/x-msgpack"},
)
identity.toml
All identity state for a host lives in ~/.muse/identity.toml.
Each section is keyed by host[:port] for human entries, or
host[:port]#handle for agent entries. One file, one source of
truth — no per-repo credential config.
# Human entry — one per hub
["localhost:10003"]
type = "human"
handle = "gabriel"
algorithm = "ed25519"
fingerprint = "sha256:a3f2c9d8..." # sha256:<64-hex> fingerprint of public key
hd_path = "m/1075233755'/1660078172'/0'/0'/0'/0'" # SLIP-0010 derivation path
# Agent entry — provisioned by gabriel on this hub
["localhost:10003#claude-code"]
type = "agent"
handle = "claude-code"
algorithm = "ed25519"
fingerprint = "sha256:b7e1a4c2..."
hd_path = "m/1075233755'/1660078172'/1'/0'/0'/0'"
provisioned_by = "gabriel"
provisioned_by_fingerprint = "sha256:a3f2c9d8..."
capabilities = ["push", "pull", "commit"]
| Field | Type | Present on | Description |
|---|---|---|---|
| type | string | all | "human" or "agent" |
| handle | string | all | Hub-assigned username |
| algorithm | string | all | "ed25519" (reserved: "ml-dsa-65") |
| fingerprint | string | all | sha256:<64-hex> fingerprint of the public key |
| hd_path | string | all | SLIP-0010 derivation path, e.g. "m/0'" |
| provisioned_by | string | agent | Handle of the human who spawned this agent |
| provisioned_by_fingerprint | string | agent | Fingerprint of the provisioning key |
| capabilities | list | agent | Allowed operations: "push", "pull", "commit", … |
| mnemonic | string | human (memory only) | BIP-39 phrase; injected at startup, never persisted |
Cryptographic value codec
Every cryptographic value is always canonically prefixed. Never a bare hex string or bare base64 blob. The prefix embeds the algorithm, making every value self-describing across storage, wire, and logs.
sha256:<64-hex> — content-addressed object / commit / snapshot ID
ed25519:<base64url> — Ed25519 signature or public key (no padding)
mldsa65:<base64url> — post-quantum signature (reserved)
# Split without decoding — use when you need the string form
split_id("sha256:abc...") → ("sha256", "abc...")
split_sig("ed25519:AAA...") → ("ed25519", "AAA...")
split_pubkey("ed25519:BBB...") → ("ed25519", "BBB...")
# Decode to raw bytes — use for cryptographic operations only
decode_sig("ed25519:AAA...") → ("ed25519", b"...")
decode_pubkey("ed25519:BBB...") → ("ed25519", b"...")
# Encode raw bytes to prefixed string
encode_sig("ed25519", raw_bytes) → "ed25519:<base64url>"
encode_pubkey("ed25519", raw_bytes) → "ed25519:<base64url>"
# Just the algo name
sig_algo("ed25519:AAA...") → "ed25519"
.removeprefix("ed25519:"),
no [10:], no inline .partition(":"). Always use
split_sig / split_pubkey / split_id.
Never store or transmit bare bytes — always encode with
encode_sig / encode_pubkey before serialisation.
Trust chains
The identity graph is a DAG with three node types and two edge types. Humans are the only root nodes — they derive authority from their key pair. Agents are provisioned by (spawned from) humans or other agents. Organisations exist as collective entities whose authority is the quorum of their members.
Nodes: HUMAN AGENT ORG
Edges: spawns(from, to) — from provisions to (human→agent, agent→agent)
member_of(member, org) — member joins org (human, agent, or org → org)
Example trust chain:
gabriel (HUMAN)
├─[spawns]──▶ claude-code (AGENT)
│ └─[spawns]──▶ worker-7 (AGENT)
├─[member_of]──▶ musehub-org (ORG, quorum=1)
└─[member_of]──▶ graph-lab (ORG, quorum=2)
├─◀── gabriel
├─◀── claude-code
└─◀── musehub-org
The graph is append-only and enforced at push time by
IdentityPushValidator on the hub side. Three invariants must hold
on every push — in order, hard errors before warnings:
| Invariant | Name | Enforcement | Effect on push |
|---|---|---|---|
| I1 | Acyclicity | hard error | Push rejected; offending edge listed in errors |
| I2 | Root distance | warning | Push accepted; orphaned node annotated |
| I3 | Authorization | hard error | Push rejected; missing signature listed |
Attestations
An attestation is an Ed25519-signed claim one identity makes about
another — the same key material, the same primitives, and the same canonical-prefix
discipline as the rest of this page. The claim is content-addressed by the SHA-256 of
its canonical message, verified entirely against the attester's registered public key,
and preserved forever in the audit trail. Attestations are how the identity DAG above
acquires semantic edges — beyond spawns and
member_of, an attestation can record that one identity reviewed another's
code, approved a master, verified a skill, or simply trusts them.
Canonical message
The ATTEST domain separator is the entire reason cross-protocol replay
is impossible. An MSign auth header signed under MUSE-SIGN-V1 can never
be replayed as an attestation, and an MPay claim signed under MPAY can
never be replayed as a code review. The canonical message is UTF-8, newline-separated,
with no trailing newline:
ATTEST\n{attester}\n{subject}\n{claim}\n{issued_at_iso}
ATTEST\n{attester}\n{subject}\n{claim}\n{issued_at_iso}\n{scope_ref}
{claim} is a compact JSON object whose top-level "type"
key must match a registered claim type. Compactness is enforced by serialising with
json.dumps(claim, separators=(",", ":"), sort_keys=True) so the same
logical claim always produces the same bytes.
# Identity scope — 5 lines
parts = ["ATTEST", attester, subject, claim_json, issued_at_iso]
# Repo / commit scope — 6 lines
if scope != "identity":
parts.append(scope_ref)
msg = "\n".join(parts).encode("utf-8")
signature = privkey.sign(msg) # Ed25519, RFC 8032
sig_str = encode_sig("ed25519", signature) # → "ed25519:<base64url>"
The content-addressed identifier is independent of the signature — two attestations
with the same canonical message collapse to the same row, which is why
create is idempotent:
attestation_id = sha256(attester NUL subject NUL claim NUL issued_at_iso)
The 17 claim types
Every attestation declares exactly one claim type. The registry is seeded in
musehub.services.musehub_attestations and mirrored to the
musehub_attestation_claim_types table — the DB is authoritative at
runtime and accepts new types via add_claim_type, but every node ships
with the same 17 seed entries below. Each type pre-declares which scopes it makes
sense in; mismatches are rejected before any signature is verified.
| Category | Type key | Label | Valid scopes | Meaning |
|---|---|---|---|---|
| identity | human | Human | identity | Subject is a verified human. |
org | Organisation | identity | Subject is a legitimate organisation. | |
agent | Agent | identity | Subject is a trustworthy agent. | |
| trust | spawned-by | Spawned By | identity | Subject agent was provisioned by attester. |
delegate | Delegate | identity | Attester delegated authority to subject. | |
trusted | Trusted | identity | Attester generally trusts subject. | |
| collab | collab | Collaborator | identity, repo, commit | Attester and subject collaborated. |
co-author | Co-author | identity, repo, commit | Subject co-authored something with attester. | |
contractor | Contractor | identity | Subject performed contracted work for attester. | |
| code | code:reviewed | Code Reviewed | commit, repo | Attester reviewed subject's code. |
code:approved | Code Approved | commit, repo | Attester approved a specific code delivery. | |
deploy:approved | Deploy Approved | commit | Attester approved a deployment at this commit. | |
| music | stems:verified | Stems Verified | identity, commit | Attester verified the authenticity of subject's stems. |
mix:approved | Mix Approved | identity, commit | Attester approved a mix by subject. | |
midi:generated | MIDI Generated | identity, commit | Attester confirms subject generated the MIDI. | |
master:approved | Master Approved | identity, commit | Attester approved a master by subject. | |
| skill | skill:verified | Skill Verified | identity | Attester verified a declared skill of subject. |
--metadata '{"key":"value"}' on attestation create
to attach extra fields inside the claim JSON — e.g.
{"type":"skill:verified","skill":"counterpoint"}. Anything you put in
the metadata becomes part of the canonical message and is therefore covered by the
signature; rewrite it after the fact and the signature breaks.
The three scopes
Scope answers "what is this attestation about?" The scope is part of the
canonical message via scope_ref, which means an attestation that says
"I reviewed gabriel/musehub at commit sha256:abc…" cannot be replayed as
"I reviewed gabriel/musehub at commit sha256:def…" — the bytes don't
match and the signature fails.
| Scope | scope_ref format | Required fields | Use for |
|---|---|---|---|
identity |
— (omitted) | — | Claims about a handle as a whole — humanity, org status, trust, skill verification. |
repo |
{handle}/{repo_slug} |
scope_ref |
Claims about a specific repository — collaboration, ongoing review. |
commit |
{handle}/{repo_slug}@sha256:{commit_id} |
scope_ref, commit_id |
Claims about a specific delivery — code review, deploy approval, master sign-off. |
The validator runs in this order, and stops at the first failure: (1)
claim type exists and is not deprecated; (2) scope is in the claim
type's valid_scopes; (3) required scope fields are
present; (4) Ed25519 signature verifies against the attester's
registered public key. Cheap checks first; cryptography last.
CLI commands
| Task | Command |
|---|---|
| Issue an identity-scope attestation | muse hub attestation create --subject <handle> --type <claim_type> |
| Issue a repo-scope attestation | muse hub attestation create --subject <handle/repo> --type <claim_type> --scope repo --scope-ref <handle/repo> |
| Issue a commit-scope attestation | muse hub attestation create --subject <handle/repo> --type <claim_type> --scope commit --scope-ref <handle/repo@sha256:…> --commit-id <sha256:…> |
| Attach extra claim fields | muse hub attestation create … --metadata '{"skill":"counterpoint"}' |
| Issue with an expiry | muse hub attestation create … --expires-in 90d |
| List attestations about a subject | muse hub attestation list --subject <handle> --json |
| Filter list by attester | muse hub attestation list --subject <handle> --attester <handle> --json |
| Filter list by claim type | muse hub attestation list --subject <handle> --type <claim_type> --json |
| Include revoked entries | muse hub attestation list --subject <handle> --include-revoked --json |
| Revoke (only the original attester) | muse hub attestation revoke <attestation_id> --subject <handle> |
# gabriel attests that claude-code is a trustworthy agent
muse hub attestation create --subject claude-code --type agent --json
# gabriel reviews a specific commit on his own repo
muse hub attestation create \
--subject gabriel/musehub --type code:reviewed \
--scope commit \
--scope-ref gabriel/musehub@sha256:abc123... \
--commit-id sha256:abc123... \
--json
# list everything attested about gabriel
muse hub attestation list --subject gabriel --json
# revoke an old attestation (only the original attester can do this)
muse hub attestation revoke sha256:af54753d... --subject claude-code --json
The CLI loads the private key from ~/.muse/identity.toml (or the agent
sub-key injected via MUSE_AGENT_KEY_FD), builds the canonical message,
signs it locally, and POSTs the signature plus public key to
/api/profiles/{handle}/attestations. The hub never sees the private key
— it only verifies the signature against the public key it already has on file from
the attester's registration.
REST API
| Method | Path | Description |
|---|---|---|
GET |
/api/profiles/{handle}/attestations |
List attestations about {handle} (add ?include_revoked=true to include retired entries). |
POST |
/api/profiles/{handle}/attestations |
Issue a new attestation. Body is AttestationRequest. Idempotent on (attester, subject, claim, issued_at). |
DELETE |
/api/profiles/{handle}/attestations/{id}?revoker={handle} |
Soft-revoke. Sets revoked_at; row is preserved for audit. Only the original attester succeeds. |
GET |
/api/profiles/attestation-types |
List all registered claim types (add ?include_deprecated=true for retired entries). |
revoked_at timestamp set, never deleted. This is by design:
downstream consumers may have already cached or relied on the attestation, and the
audit trail is what makes the system tamper-evident. Default queries exclude
revoked rows; pass --include-revoked or
?include_revoked=true to see them.
The three invariants
I1 — Acyclicity
The identity graph must be a DAG at all times. Before accepting any new
edge from → to, the validator runs a DFS from to
and rejects the push if it reaches from. Self-loops
(from == to) are rejected immediately without traversal.
Both spawns and member_of edges share the same DAG
universe — a cross-type cycle (e.g. alice →spawns→ bot →member_of→ alice)
is equally rejected.
I2 — Root distance
Every node should have a path to at least one human root. The validator
runs a multi-source BFS from all HUMAN nodes and reports any
node with no path as an orphan. An orphaned agent or org is not
necessarily a bug — the push is accepted — but the node is flagged in
ValidationResult.warnings for the caller to inspect.
I3 — Authorization
Every relationship must carry cryptographic signatures proving consent. The rules differ by edge type:
| Edge type | Required signers | Notes |
|---|---|---|
spawns(A → B) |
A must sign |
Only the spawner can authorise the spawn. Extra signers are permitted. |
member_of(M → Org) — founding |
M must self-sign |
Org has zero prior members. Bootstrap pattern. |
member_of(M → Org) — subsequent |
min(quorum, |prior|) existing members |
Processed in input order. Each relationship sees only members committed before it. |
from musehub.graph.push_validator import IdentityPushValidator
validator = IdentityPushValidator()
result = validator.validate(
identities=[
{"handle": "gabriel", "type": "human", "quorum": None},
{"handle": "claude-code", "type": "agent", "quorum": None},
{"handle": "acme", "type": "org", "quorum": 2},
],
relationships=[
{
"from_handle": "gabriel",
"to_handle": "claude-code",
"edge_type": "spawns",
"authorized_by": [
{"signer": "gabriel", "signature": "ed25519:...", "signed_at": "2026-04-21T..."}
],
},
],
)
# result.valid → True / False
# result.errors → ["I1 violation: ...", "I3 violation: ..."]
# result.warnings → ["I2 warning: 'acme' has no path to any human root"]
Quorum authorization
Organisations have no key of their own. Their authority is the
quorum of their members — distributed threshold authorisation rather than
a single key that can be stolen or lost. The quorum field on an
org record sets the threshold; members carry fractional
weight values so voting power can be non-uniform.
Relationships are processed in input order — each one sees only the members
committed before it in the same push. This mirrors an append-only event log
and solves the bootstrap problem cleanly: the first member of an empty org
self-authorises; subsequent members need min(quorum, |prior|)
existing member signatures.
Step 1: gabriel joins graph-lab (0 prior members)
→ bootstrap: gabriel self-signs ✓ required: 1 sig (self)
Step 2: claude-code joins graph-lab (1 prior member: gabriel)
→ min(quorum=2, prior=1) = 1 ✓ required: 1 sig from {gabriel}
Step 3: musehub-org joins graph-lab (2 prior members: gabriel, claude-code)
→ min(quorum=2, prior=2) = 2 ✓ required: 2 sigs from {gabriel, claude-code}
Step 4: alice tries to join (3 prior members: gabriel, claude-code, musehub-org)
→ min(quorum=2, prior=3) = 2 ✓ required: any 2 of the 3 existing members
Sub-organisations can vote in their own member_of relationships, but only
if their own quorum is independently met. The
QuorumEngine.effective_weight() method descends recursively to
validate that an org's vote is backed by sufficient member signatures before
counting it toward the parent org's threshold.
from musehub.graph.quorum import OrgSpec, QuorumEngine, VoteRecord
from decimal import Decimal
orgs = {
"graph-lab": OrgSpec(
handle="graph-lab",
quorum=2,
member_weights={
"gabriel": Decimal("1"),
"claude-code": Decimal("1"),
},
),
}
engine = QuorumEngine(orgs)
votes = [
VoteRecord(voter_handle="gabriel", org_handle="graph-lab"),
VoteRecord(voter_handle="claude-code", org_handle="graph-lab"),
]
engine.is_quorum_met("graph-lab", votes) # → True
Worked 2-of-3 example — admitting a new member
The hub verifies quorum at push time by inspecting the authorized_by
array on each relationship. Here is the full push payload for admitting
alice to graph-lab (quorum=2, three prior members):
{
"identities": [
{ "handle": "alice", "type": "human", "quorum": null,
"public_key": "ed25519:aL1mN2oP3qR4sT5uV6wX7yZ8aB9cD0eF1gH2iJ3kL4" }
],
"relationships": [
{
"from_handle": "alice",
"to_handle": "graph-lab",
"edge_type": "member_of",
"authorized_by": [
{
"signer": "gabriel",
"signature": "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9",
"signed_at": "2026-04-21T16:00:00Z"
},
{
"signer": "claude-code",
"signature": "ed25519:Yf4lB8nCdD3eF6gH9iJ2kL5mN7oP1qR4sT7uV0wX3",
"signed_at": "2026-04-21T16:01:00Z"
}
]
}
]
}
{
"valid": true,
"errors": [],
"warnings": []
}
If only one signature is provided — below the quorum threshold — the hub rejects the push immediately:
{
"valid": false,
"errors": [
"I3 violation: member_of(alice → graph-lab) requires 2 signatures from existing members, got 1"
],
"warnings": []
}
Quorum in governance.json — handle-based members
Repo governance uses the same quorum concept. A repo is governed when its HEAD
snapshot contains a governance.json file. The hub resolves member
handles to fingerprints at merge time by reading each member's identity repo HEAD
— so key rotation propagates automatically without updating governance.json.
{
"quorum": {
"threshold": 2,
"members": ["gabriel", "alice"]
}
}
Raw sha256: fingerprints are also accepted for backward compatibility,
but handles are preferred — they track key rotation without any update to the file.
{
"quorum": {
"threshold": 2,
"members": ["sha256:a3f2c9d8...", "sha256:b7e1a4c2..."]
}
}
Handle resolution reads identities/{handle}.json from the member's
identity repo HEAD, decodes the pubkey field, and derives the
sha256: fingerprint. If the identity repo is absent or the handle
cannot be resolved, that member is skipped and cannot contribute to the quorum.
Auth CLI
| Task | Command |
|---|---|
| Generate new keypair | muse auth keygen --hub <hub-url> |
| Register key on hub | muse auth register --hub <url> --handle <handle> |
| Show current identity | muse auth whoami --json |
| Full HD identity details | muse auth show --json |
| Rotate to next HD index | muse auth rotate --hub <url> --json |
| Recover key from mnemonic | muse auth recover --hub <url> --json |
| Decommission (logout) | muse auth logout --hub <url> --json |
| Decode an HD path to human-readable labels | muse path annotate <hd-path> --json |
| Migrate pre-Phase-1 keys to hash-derived paths | muse migrate domain-integers --json |
| Dry-run migration (no writes) | muse migrate domain-integers --dry-run --json |
# 1. Generate keypair — outputs a BIP-39 mnemonic; store it securely
muse auth keygen --hub http://staging.musehub.ai
# 2. Register the public key with the hub
muse auth register --hub http://staging.musehub.ai --handle gabriel
# 3. Verify
muse auth whoami
# → handle: gabriel hub: <your-hub-host> algo: ed25519
MUSE_AGENT_KEY_FD (a pipe file descriptor) and the agent's handle
via MUSE_AGENT_HANDLE. The agent's get_signing_identity()
reads the fd first, derives its Ed25519 key via SLIP-0010, then falls back to
~/.muse/identity.toml. The root mnemonic never leaves the parent process.
Key rotation
Three distinct values describe a Muse cryptographic identity. Confusing them is the most common source of auth bugs — especially after key rotation.
| Field | Derived from | Changes on rotation? | Where it lives |
|---|---|---|---|
identity_id |
sha256(first_registered_key_bytes) |
Never — immutable anchor | musehub_identities.identity_id |
fingerprint |
sha256(current_key_bytes) |
Yes — new value per rotation | musehub_auth_keys.fingerprint |
public_key_b64 |
Raw 32-byte Ed25519 public key, base64url (no padding) | Yes — new value per rotation | musehub_auth_keys.public_key_b64 |
identity_id is computed once when you first register and stored in
musehub_identities. It is the stable anchor for your identity — every
repo you create, every commit you sign, and every key you ever rotate to is linked
back to this single value. It never changes.
fingerprint and public_key_b64 belong to individual keys,
not the identity. They live in musehub_auth_keys, one row per
registered key. An identity can have many registered keys simultaneously — one per
device, one per agent, one from before a rotation. Deleting a row immediately
revokes that key.
muse auth keygen vs muse auth rotate
These two commands look similar but have fundamentally different effects:
muse auth keygen | muse auth rotate | |
|---|---|---|
| What it does | Generates a new BIP-39 mnemonic, derives key at index 0 | Derives the next HD index from the existing mnemonic |
identity_id |
New value — a new identity | Unchanged — same identity |
| If handle is already registered | 409 Conflict — handle is taken |
Succeeds — adds a new key row to the existing identity |
| Old key after the operation | Still registered (on the old identity) | Still valid until you explicitly revoke it |
| Use when | First-time setup only, or you intentionally want a fresh identity | Routine rotation, machine migration, compromise response |
muse auth keygen when you already have a registered handle
will not replace your existing identity. It generates a completely
new mnemonic and a new identity_id. Attempting to register the new
key under your existing handle returns 409 Conflict. Use
muse auth rotate for key rotation.
Rotation flow
# Derive next key, register it with the hub, update identity.toml
muse auth rotate --hub http://staging.musehub.ai --json
# identity_id is unchanged. A second row now exists in musehub_auth_keys.
# The old key is still valid. Revoke it once you confirm the new key works:
muse auth whoami --json # confirm new fingerprint is active
muse auth logout --hub http://staging.musehub.ai --key-id <old-key-id> --json
After rotation the hub holds two rows for your identity in
musehub_auth_keys:
[
{
"identity_id": "sha256:41dfaf04…", // ← never changes
"fingerprint": "sha256:a3f2c9d8…", // old key
"algorithm": "ed25519",
"public_key_b64": "3aB7kLmN…",
"label": "macbook-pro",
"created_at": "2026-04-01T10:00:00Z"
},
{
"identity_id": "sha256:41dfaf04…", // ← same identity_id
"fingerprint": "sha256:d8faf800…", // new key after rotation
"algorithm": "ed25519",
"public_key_b64": "qR5sT6uV…",
"label": "macbook-pro (rotated)",
"created_at": "2026-05-01T09:00:00Z"
}
]
What the profile page displays
The profile hero strip always shows the most recently registered key
for the identity — the row from musehub_auth_keys with the latest
created_at for that identity_id. It does not display the
identity_id as the key — the identity_id is a stable
account anchor, not a per-request credential.
| Profile strip row | Source |
|---|---|
Algorithm label (ed25519) |
musehub_auth_keys.algorithm — most recent key |
Public key (3aB7kLmN…) |
musehub_auth_keys.public_key_b64 — most recent key |
Fingerprint (sha256:d8fa…) |
musehub_auth_keys.fingerprint — most recent key |
| Registered date | musehub_identities.created_at — identity registration date, not key rotation date |
If no key row exists for an identity (e.g. the identity was created through a code
path that bypassed the standard auth flow), the profile falls back to displaying
the identity_id as the fingerprint. This is a degraded state — the
correct fix is to run muse auth rotate to register an active key.
The rotation invariant
The identity is the mnemonic, not the key. Keys are disposable
credentials derived from the mnemonic. You can rotate as often as you like and hold
multiple active keys simultaneously. None of this changes who you are — your
identity_id, your repos, your commits, your attestations are all
anchored to the mnemonic, not to any individual key.
Agent sub-keys
Every agent in the ecosystem inherits its signing key from the parent's root mnemonic via HD derivation — not a fresh mnemonic. The parent process (human shell or Agentception orchestrator) derives a 64-byte sub-seed at a specific HD path and passes it to the spawned agent over a pipe. The agent's process never sees the root mnemonic.
Key injection — two environment variables
| Variable | Value | Purpose |
|---|---|---|
MUSE_AGENT_KEY_FD |
integer (file descriptor number) | Read end of a pipe carrying 64 raw bytes — the agent's sub-seed |
MUSE_AGENT_HANDLE |
string | The agent's registered handle on the hub; paired with the injected key |
Identity resolution order
get_signing_identity() in muse.core.auth checks
these sources in order, using the first one that is set:
1. MUSE_AGENT_KEY_FD set?
→ read exactly 64 bytes from that fd
→ derive Ed25519 keypair via SLIP-0010
→ handle = MUSE_AGENT_HANDLE
2. MUSE_AGENT_HD_SEED set?
→ treat as hex-encoded 64-byte sub-seed
→ derive Ed25519 keypair via SLIP-0010
3. ~/.muse/identity.toml present?
→ load matching [host] or [host#handle] section
4. None of the above → IdentityNotFound raised
Agentception injection sequence
import os, subprocess
from muse.core.identity import derive_sub_seed
# Derive a domain-scoped sub-seed for agent slot 7
sub_seed = derive_sub_seed(
root_seed=orchestrator_root_seed,
domain=678195575, # Code domain (muse/code)
entity_type=1, # Agent
entity_id=7, # Unique slot
) # → 64 bytes; cannot produce Identity or Payment keys
# Open a pipe; write the sub-seed into the write end and close it
read_fd, write_fd = os.pipe()
os.write(write_fd, sub_seed) # exactly 64 bytes
os.close(write_fd)
# Spawn the agent with the read fd and handle in its environment
proc = subprocess.Popen(
["python3", "-m", "my_agent"],
env={
**os.environ,
"MUSE_AGENT_KEY_FD": str(read_fd),
"MUSE_AGENT_HANDLE": "worker-7",
},
pass_fds=(read_fd,),
)
os.close(read_fd) # orchestrator closes its copy after spawn
from muse.core.auth import get_signing_identity
# muse commit, muse push, muse sign all call this internally.
# The agent process never needs to call it directly.
identity = get_signing_identity(hub="http://staging.musehub.ai")
# → SigningIdentity(handle="worker-7", algorithm="ed25519",
# public_key="ed25519:...")
domain=678195575) can only derive Code-domain keys — it physically
cannot produce an Identity (domain=1660078172') or Payment
(domain=284229149') key. There is no policy rule enforcing this;
the HD math simply does not connect those branches.
Identity repos
Every registered identity on MuseHub has a dedicated Muse repository:
{handle}/identity with domain="identity".
This repo is the canonical source of truth for a principal's
public key, quorum threshold, and relationship graph — the PostgreSQL tables
are a queryable index rebuilt from it, not the other way around.
Because the identity repo is a normal Muse repo, you get content-addressed
history for free: every key rotation, every membership change, and every
spawns relationship is a commit with a tamper-evident SHA-256 ID, auditable
with muse log.
IdentityRecord — the file stored in the repo
Each identity repo stores one JSON file at
identities/{handle}.json (the IdentityRecord)
and zero or more relationship files at
relationships/{from}--{edge}--{to}.json:
{
"handle": "gabriel",
"type": "human",
"pubkey": "ed25519:scbtcAeEYMv3cCBNcYJU153gqaT1UpSBVDVttTj_9-Y",
"quorum": null,
"registered_at": "2026-04-21T14:32:07Z",
"metadata": { "display_name": "Gabriel" }
}
| Field | Type | Description |
|---|---|---|
handle | str | Hub username — matches the repo owner slug |
type | "human" | "agent" | "org" | Principal type |
pubkey | ed25519:<base64url> | null | Current signing public key. Null for orgs (authority is quorum, not a single key) |
quorum | int | null | Approval threshold for org governance. Null for humans and agents |
registered_at | ISO-8601 string | When this identity was first registered |
metadata | object | Optional display fields: display_name, etc. |
Repo layout
identities/
gabriel.json ← IdentityRecord
relationships/
gabriel--spawns--claude-code.json ← RelationshipRecord
gabriel--member_of--acme-org.json ← RelationshipRecord
REST API — reading from the repo
GET /api/identities/{handle} reads pubkey,
quorum, identity_type, and display_name
from the identity repo HEAD. If the repo is absent (e.g. pre-registration or
migration period) the response falls back to the DB values.
curl http://staging.musehub.ai/api/identities/gabriel
{
"handle": "gabriel",
"identity_type": "human",
"pubkey": "ed25519:scbtcAeEYMv3cCBNcYJU153gqaT1UpSBVDVttTj_9-Y",
"quorum": null,
"display_name": "Gabriel",
"fingerprint": "sha256:220897cdf3a...",
"created_at": "2026-04-21T14:32:07Z"
}
Key rotation as a commit
muse auth rotate does two things atomically: it re-registers the new
public key with the hub and commits an updated IdentityRecord to the
identity repo. This gives a full, tamper-evident rotation history:
muse auth rotate --hub http://staging.musehub.ai --json
# Rotates to the next HD index, updates hub registration,
# and commits a new identities/gabriel.json to gabriel/identity.
# View the full rotation history
muse -C gabriel/identity log --json
The Python API for reading the canonical record directly from the repo:
from muse.plugins.identity.records import (
IdentityRecord, RelationshipRecord,
identity_path, relationship_path,
record_to_bytes,
)
# Build a record
record: IdentityRecord = {
"handle": "gabriel",
"type": "human",
"pubkey": "ed25519:...",
"quorum": None,
"registered_at": "2026-04-21T14:32:07Z",
"metadata": {"display_name": "Gabriel"},
}
# File paths in the identity repo
identity_path("gabriel") # → "identities/gabriel.json"
relationship_path("gabriel", "spawns", "claude-code") # → "relationships/gabriel--spawns--claude-code.json"
Key recovery
If ~/.muse/identity.toml is lost or you move to a new machine,
muse auth recover rebuilds your identity from the 24-word BIP-39
mnemonic stored in your password manager. Every derived key is deterministic —
the same mnemonic always produces the same key tree.
$ muse auth recover --hub http://staging.musehub.ai
Enter your 24-word BIP-39 mnemonic (words separated by spaces):
> abandon ability able about above absent absorb abstract absurd abuse
access accident account accuse achieve acid acoustic acquire across
act action actor actress actual
✔ Mnemonic valid (24 words, checksum OK)
Deriving keys from mnemonic...
domain=1660078172' (Identity), entity_type=0' (Human), entity_id=0', index=0'
hd_path: m/1075233755'/1660078172'/0'/0'/0'/0'
public_key: ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM
fingerprint: a3f2c9d8e1b4...
Checking hub registration...
✔ Key registered — handle: gabriel
Restored ~/.muse/identity.toml
[staging.musehub.ai]
handle = "gabriel"
fingerprint = "a3f2c9d8e1b4..."
hd_path = "m/1075233755'/1660078172'/0'/0'/0'/0'"
muse auth keygen — pipe it
from the keychain directly to your password manager so it never appears in
terminal scrollback:
security find-generic-password -s muse -a mnemonic -w | pbcopy (macOS)
or
secret-tool lookup service muse account mnemonic | xclip -selection clipboard (Linux).
Losing the mnemonic means permanent loss of all derived keys.
Migration — pre-Phase-1 keys
Muse Phase 1 replaced sequential domain integers (0–6) with hash-derived
values computed as
int.from_bytes(sha256(name.encode())[:4], "big") & 0x7FFFFFFF.
Users who generated their identity before Phase 1 have keys derived at the
old paths. muse migrate domain-integers detects those entries in
~/.muse/identity.toml, re-derives the correct keys at the new
paths, and re-registers each fingerprint with its hub.
# Inspect — see what would change (no writes)
muse migrate domain-integers --dry-run --json
# Apply — re-derive keys and re-register with each hub
muse migrate domain-integers --json
# Re-derive keys and update identity.toml, but skip hub re-registration
muse migrate domain-integers --no-register --json
{
"dry_run": true,
"entries_found": 1,
"entries_migrated": 0,
"results": [
{
"hub_key": "staging.musehub.ai",
"old_hd_path": "m/1075233755'/0'/0'/0'/0'/0'",
"new_hd_path": "m/1075233755'/1660078172'/0'/0'/0'/0'",
"old_fingerprint": "sha256:a3f2c9d8...",
"new_fingerprint": "sha256:b7e1a4c2...",
"hub_registered": false
}
]
}
--dry-run first to inspect what will
change. The mnemonic in the keychain is unchanged — only the derived HD
path and hub registration are updated. If hub re-registration fails, the
key is still re-derived locally; run the command again once the hub is
reachable.
MPay — Micropayments
MPay is the native micropayment layer built directly on the same Ed25519 identity infrastructure as MSign. There are no payment processors, no API keys, no OAuth scopes — a payment is just a canonical message signed by the sender's key. Units are nanoMUSE (10−9 MUSE).
Canonical message
The message signed by the sender is UTF-8 encoded, newline-separated:
MPAY
{sender_handle}
{recipient_handle}
{amount_nano}
{nonce_hex}
nonce_hex is a random hex string generated by the sender for each
payment. The server enforces a unique constraint on nonces — submitting the same
nonce twice returns the existing claim (idempotent).
Full sign → submit → verify worked example
Step 1 — sign the payment. muse sign payment prints the canonical
message, the Ed25519 signature, and the complete MPayClaimRequest
payload ready to POST:
muse sign payment \
--from gabriel \
--to alice \
--amount 1000000 \
--nonce a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3 \
--json
{
"canonical_message": "MPAY\ngabriel\nalice\n1000000\na3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
"signature": "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9yZ1",
"sender_public_key": "ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM",
"claim_request": {
"sender": "gabriel",
"recipient": "alice",
"amount_nano": 1000000,
"nonce_hex": "a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
"signature": "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9yZ1",
"sender_public_key": "ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM",
"memo": null
}
}
Step 2 — submit the claim. Pipe the claim_request object to
POST /mpay/claim using muse sign request to
attach the MSign Authorization header:
# Write the claim_request object to a file
muse sign payment --from gabriel --to alice --amount 1000000 \
--nonce a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3 --json \
| python3 -c "import sys,json; json.dump(json.load(sys.stdin)['claim_request'], open('/tmp/claim.json','w'))"
# Sign and POST
muse sign request \
--method POST \
--url http://staging.musehub.ai/mpay/claim \
--body-file /tmp/claim.json \
--json
{
"claim_id": "sha256:7f4a2b8cd3e9f1a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
"sender": "gabriel",
"recipient": "alice",
"amount_nano": 1000000,
"nonce_hex": "a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
"signature": "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9yZ1",
"sender_public_key": "ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM",
"memo": null,
"created_at": "2026-04-21T15:00:00Z",
"confirmed_at": null,
"voided_at": null
}
Step 3 — query the ledger to verify settlement:
muse sign request \
--method GET \
--url http://staging.musehub.ai/mpay/ledger/gabriel \
--json
MPayClaimRequest fields
| Field | Type | Description |
|---|---|---|
sender | str | Sending identity handle |
recipient | str | Receiving identity handle |
amount_nano | int | Amount in nanoMUSE (10−9 MUSE) |
nonce_hex | str | Exactly 64-char hex string (32 random bytes); server enforces uniqueness per sender |
signature | str | ed25519:<base64url> — signature over the canonical message |
sender_public_key | str | ed25519:<base64url> — sender's public key for verification |
memo | str | null | Optional human-readable note attached to the payment |
MPayClaimResponse fields
| Field | Type | Description |
|---|---|---|
claim_id | str | sha256:<64-hex> content-addressed claim ID |
sender | str | Sending identity handle |
recipient | str | Receiving identity handle |
amount_nano | int | Payment amount in nanoMUSE |
nonce_hex | str | 64-char hex nonce from the original request |
signature | str | ed25519:<base64url> — sender's signature |
sender_public_key | str | ed25519:<base64url> — sender's public key |
memo | str | null | Optional payment memo |
created_at | str | UTC timestamp of claim creation |
confirmed_at | str | null | UTC timestamp of settlement; null if pending |
voided_at | str | null | UTC timestamp if claim was voided; null otherwise |
Ledger query
The ledger endpoint returns all sent and received claims for an identity, with running totals:
{
"handle": "gabriel",
"sent": [
{
"claim_id": "sha256:7f4a2b8cd3e9f1a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8",
"sender": "gabriel",
"recipient": "alice",
"amount_nano": 1000000,
"nonce_hex": "a3f9c2d1b4e87f560c8a2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3",
"signature": "ed25519:Xe3kA7mBnC2dE5fG8hI1jK4lM6nO9pQ0rS3tU6vW9yZ1",
"sender_public_key": "ed25519:3aB7kLmNpQ2rS4tU5vW6xY7zA8bC9dE0fG1hI2jK3lM",
"memo": null,
"created_at": "2026-04-21T15:00:00Z",
"confirmed_at": "2026-04-21T15:00:03Z",
"voided_at": null
}
],
"received": [],
"total_sent_nano": 1000000,
"total_received_nano": 0
}
sender, recipient, amount_nano,
nonce_hex) do not match the signature. The server also checks
that sender_public_key is registered to the sender
handle in MusehubAuthKey before accepting.