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
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:
- 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).
- 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:
jss start --single-user --provision-keys --port 4444
curl -i http://localhost:4444/private/privkey.jsonld → 401
- Authenticated GET → returns valid CID v1.0 Multikey JSON-LD
- Parse with
multibase library → keys decode correctly
- Test Schnorr signature using the secret key against a known message
- 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.
Summary
Add a
--provision-keysCLI flag for pod creation that generates a Schnorr secp256k1 keypair and writes it to/private/privkey.jsonldin 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-keysKey generation
@noble/curves/secp256k1File 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
nostrextension block is non-standard but lets Nostr-native tools grabnsec/npubdirectly without computing from multibase. CID-aware tools use the spec-standardsecretKeyMultibase/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:
Tests
--provision-keyswrites file to correct path/private/correctly restricts to ownerDocumentation
docs/provision-keys.md— feature overview, security notes, recovery flowdocs/key-recovery.md— backup recommendations, restore processPhase 1 acceptance criteria
--provision-keysflag added tojss start --single-userand pod creation@noble/curves/private/privkey.jsonldwritten with CID v1.0 Multikey format + nostr extension/private/set to owner-onlyPhase 1 explicit NON-goals (deferred to future phases)
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), thedid: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.0verificationMethodslot (src/webid/profile.js), the pubkey extractor (src/auth/nostr-keys.js).The actual Phase 2 gap is two wires:
verificationMethodarray (so the LWS-CID verifier can find it when an agent signs a JWT with the on-disk secret)./private/privkey.jsonldfrom WebID todid:nostr:<npub>(the resolver lands the round-trip).See #443 for the full plan, acceptance criteria, and tests.
Phase 3 — Backup and rotation
--seed-phraseflag generates keypair from BIP-39 mnemonicjss rotate-keyscommand for replacing keysPhase 4 — Hardware and multi-device
Design decisions logged
/private/privkey.jsonldpath: simple, ACL-protected by/private/standard, not nested in/settings/keys/(SolidOS convention), not at WebID-adjacent (that's for public key only)publicKeyMultibase,secretKeyMultibase);nostrextension block for ecosystem convenience without breaking spec conformance/private/container behaviour; verify or set explicitly during provisioningTest plan
test/keys/test/integration/provision-keys.test.jsjss start --single-user --provision-keys --port 4444curl -i http://localhost:4444/private/privkey.jsonld→ 401multibaselibrary → keys decode correctlyReferences
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:
BACK UPwarning expanded to include the threat model (filesystem reads bypass WAC).docs/provision-keys.mdincludes a "Protect this file" section recommending FDE / LUKS / OS keyring / restrictiveumask.0600mode (POSIX) so even other unix users on the box can't read it.Out of scope for Phase 1: a
--key-passphraseflag 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. Keepnostr.npub.secretKeyMultibaseandnostr.nsecwould 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 sidenpubnext topublicKeyMultibaseis harmless and convenient.The
nostrextension block becomes: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 withcontroller: 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 todid:nostr:once the resolution mechanism exists. The key file is self-consistent at every phase.4. Path choice:
/private/privkey.jsonldvs/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 /.podsbody withprovisionKeys: true. No new CLI subcommand in Phase 1.Today the multi-user pod-creation entry point is
POST /.pods(HTTP). Adding ajss create-podsubcommand 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: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-usership 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.