Skip to content

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

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions