Skip to content

feat: --provision-keys flag for pod creation (Schnorr/Nostr/CID v1.0 identity) #437

@melvincarvalho

Description

@melvincarvalho

Summary

Add a --provision-keys CLI flag for pod creation that generates a Schnorr secp256k1 keypair and writes it to /private/privkey.jsonld in W3C CID v1.0 Multikey format. The single key works as: (a) the pod owner's signing identity, (b) a Nostr identity (same curve), (c) a did:nostr DID controller.

One CLI flag → pod-resident self-sovereign identity ready for both Solid and Nostr/agentic use cases. Differentiates JSS as the AGPL-substrate Solid server with native agentic identity support.

Why

The agentic use case (AI agent operates a Solid pod) requires the pod to own its credentials. Current flow: spin up pod → manually generate keys → manually write to a path → manually configure ACLs → manually wire to did:nostr. This is 4 manual steps for a deployment pattern that should be one.

This also lands JSS as a reference implementation of W3C CID v1.0 (REC) in the substrate itself — the key file IS a CID v1.0 conformant document.

Phase 1 — Minimum viable shipping scope

CLI

jss start --single-user --provision-keys
# or for multi-user
jss create-pod --owner=alice --provision-keys

Key generation

  • Algorithm: Schnorr secp256k1 via @noble/curves/secp256k1
  • Standard, well-audited, same curve as Nostr/Bitcoin
  • No new cryptographic dependencies

File written

/private/privkey.jsonld — W3C CID v1.0 Multikey format:

{
  "@context": "https://www.w3.org/ns/cid/v1",
  "type": "Multikey",
  "controller": "did:nostr:npub1...",
  "publicKeyMultibase": "z6Mk...",
  "secretKeyMultibase": "z6L...",
  "nostr": {
    "npub": "npub1...",
    "nsec": "nsec1..."
  }
}

The nostr extension block is non-standard but lets Nostr-native tools grab nsec/npub directly without computing from multibase. CID-aware tools use the spec-standard secretKeyMultibase / publicKeyMultibase.

ACL

/private/ ACL must be owner-only (Read + Write for owner WebID, no public). This is the standard /private/ container ACL — verify it's auto-set on pod creation; if not, set it explicitly during --provision-keys.

CLI output

After successful provisioning:

✓ Pod created at https://alice.example/
✓ Schnorr secp256k1 keypair generated
✓ Public key:  https://alice.example/private/privkey.jsonld
✓ Owner DID:   did:nostr:npub1...
✓ Nostr npub:  npub1...

⚠ BACK UP /private/privkey.jsonld
   Losing this file means losing this identity permanently.
   See docs/key-recovery.md

Tests

  • Unit: key generation produces valid Schnorr keypair
  • Unit: JSON-LD output is valid CID v1.0 Multikey document
  • Unit: multibase encoding is correct
  • Integration: --provision-keys writes file to correct path
  • Integration: ACL on /private/ correctly restricts to owner
  • Integration: file is readable by owner, 401 for public

Documentation

  • docs/provision-keys.md — feature overview, security notes, recovery flow
  • docs/key-recovery.md — backup recommendations, restore process
  • README mention under "CLI Reference" section

Phase 1 acceptance criteria

  • --provision-keys flag added to jss start --single-user and pod creation
  • Schnorr secp256k1 keypair generated using @noble/curves
  • /private/privkey.jsonld written with CID v1.0 Multikey format + nostr extension
  • ACL on /private/ set to owner-only
  • CLI emits prominent backup-required message
  • Tests cover generation, format, ACL, file-access
  • Documentation pages added
  • All existing tests pass

Phase 1 explicit NON-goals (deferred to future phases)

  • did:nostr DID-document publication (Phase 2)
  • WebID profile updated to reference public key (Phase 2)
  • Discovery mechanism (Phase 2)
  • Seed-phrase derivation (BIP-39 / 12-24 word backup) (Phase 3)
  • Key rotation flow (Phase 3)
  • Multi-algorithm key support (Ed25519, RSA) (Phase 3)
  • Hardware wallet integration (Phase 4)
  • Encrypted key-at-rest (Phase 4)
  • Multi-pod / multi-device key sync (Phase 4)

Future phases (sketched)

Phase 2 — close the LWS-CID loop (rescoped, sub-issue #443)

The original Phase 2 sketch overestimated what was missing. Most of the work was already in tree before #437: the LWS-CID verifier (src/auth/lws-cid.js), the did:nostr: resolver (src/auth/did-nostr.js), the well-known DID-doc publisher (src/idp/well-known-did-nostr.js), the profile generator's CID v1.0 verificationMethod slot (src/webid/profile.js), the pubkey extractor (src/auth/nostr-keys.js).

The actual Phase 2 gap is two wires:

  1. Inject the Phase 1 public key into the seeded WebID profile's verificationMethod array (so the LWS-CID verifier can find it when an agent signs a JWT with the on-disk secret).
  2. Flip the Phase 1 controller in /private/privkey.jsonld from WebID to did:nostr:<npub> (the resolver lands the round-trip).

See #443 for the full plan, acceptance criteria, and tests.

Phase 3 — Backup and rotation

  • --seed-phrase flag generates keypair from BIP-39 mnemonic
  • jss rotate-keys command for replacing keys
  • Migration path for existing pods (re-key + update WebID + notify)

Phase 4 — Hardware and multi-device

  • Integration with hardware wallets (Ledger, NIP-46 signers)
  • Encrypted key storage at rest (passphrase-derived)
  • Multi-pod sync for users with multiple devices

Design decisions logged

  • JSON-LD over TTL: aligns with CID v1.0 REC, agentic-friendly, no RDF parser dependency, scores across every tool/audience axis
  • Schnorr secp256k1: shared curve with Nostr/Bitcoin, well-audited library, one key serves multiple ecosystems
  • /private/privkey.jsonld path: simple, ACL-protected by /private/ standard, not nested in /settings/keys/ (SolidOS convention), not at WebID-adjacent (that's for public key only)
  • CID v1.0 Multikey format: spec-correct property names (publicKeyMultibase, secretKeyMultibase); nostr extension block for ecosystem convenience without breaking spec conformance
  • Owner-only ACL: standard /private/ container behaviour; verify or set explicitly during provisioning

Test plan

  • Unit tests in test/keys/
  • Integration test in test/integration/provision-keys.test.js
  • Manual verification:
    1. jss start --single-user --provision-keys --port 4444
    2. curl -i http://localhost:4444/private/privkey.jsonld → 401
    3. Authenticated GET → returns valid CID v1.0 Multikey JSON-LD
    4. Parse with multibase library → keys decode correctly
    5. Test Schnorr signature using the secret key against a known message
    6. Verify with the public key → signature valid

References


Design resolutions (pre-implementation)

Six design questions answered before opening Phase 1 PR. Logged here so future PRs have a referent.

1. Plaintext secret on disk

Decision: ship plaintext in Phase 1, document the risk loudly.

Even with owner-only WAC, anyone with filesystem access (root, backups, container layer dumps, snapshots) gets the key. Phase 4 promises encrypted-at-rest; Phase 1 ships the worst case.

Mitigations in scope for Phase 1:

  • The BACK UP warning expanded to include the threat model (filesystem reads bypass WAC).
  • docs/provision-keys.md includes a "Protect this file" section recommending FDE / LUKS / OS keyring / restrictive umask.
  • File written with 0600 mode (POSIX) so even other unix users on the box can't read it.

Out of scope for Phase 1: a --key-passphrase flag wrapping the secret with scrypt+aesgcm. Coherent fit is alongside the seed-phrase work in Phase 3.

2. Duplicate secret representation

Decision: drop nostr.nsec. Keep nostr.npub.

secretKeyMultibase and nostr.nsec would have been the same key in two formats — doubles the surface for log/error/dump leaks and makes "rotate the key" mean rewriting the same secret twice consistently. Bech32 encoding is a one-liner; document it. The public side npub next to publicKeyMultibase is harmless and convenient.

The nostr extension block becomes:

"nostr": { "npub": "npub1..." }

3. Controller field

Decision: WebID in Phase 1, did:nostr: in Phase 2.

Phase 2 ships the did:nostr: resolver. Until then, a Multikey document with controller: did:nostr:npub1... references something nothing can resolve — strict CID consumers may choke. Phase 1 uses the pod owner's WebID as controller (e.g. https://alice.example/profile/card.jsonld#me); Phase 2 swaps to did:nostr: once the resolution mechanism exists. The key file is self-consistent at every phase.

4. Path choice: /private/privkey.jsonld vs /settings/keys/

Decision: keep /private/privkey.jsonld.

Deviation from SolidOS's /settings/keys/ convention is intentional. /private/ is the obvious "private namespace" of a Solid pod and gets owner-only WAC by default in JSS-created pods. /settings/ is conventionally for preferences and type indexes; layering keys on top of that mixes concerns. Documented here so future contributors don't "fix" it.

5. Multi-user CLI

Decision: extend POST /.pods body with provisionKeys: true. No new CLI subcommand in Phase 1.

Today the multi-user pod-creation entry point is POST /.pods (HTTP). Adding a jss create-pod subcommand would mean a second pod-creation surface that has to stay in lockstep with the HTTP one. Cleaner to thread the flag through the existing surface:

curl -X POST http://localhost:4444/.pods \
  -H "Content-Type: application/json" \
  -d '{ "name": "alice", "provisionKeys": true }'

Single-user mode keeps the CLI flag (jss start --single-user --provision-keys) since there's no HTTP equivalent.

6. Default vs opt-in

Decision: opt-in, explicitly.

Keys-on-disk is a real security tradeoff (see #1). Defaulting it on would make every jss start --single-user ship a plaintext secret to disk silently. Opt-in keeps it visible. Future PRs proposing "let's default it on for ergonomics" should re-open this design call — link back here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions