diff --git a/bin/jss.js b/bin/jss.js index 48134651..15fc31a8 100755 --- a/bin/jss.js +++ b/bin/jss.js @@ -95,6 +95,8 @@ program .option('--no-notifications', 'Disable WebSocket notifications') .option('--idp', 'Enable built-in Identity Provider') .option('--no-idp', 'Disable built-in Identity Provider') + .option('--provision-keys', 'Generate a Schnorr secp256k1 owner key on pod creation, written to /private/privkey.jsonld in W3C CID v1.0 Multikey format (off by default)') + .option('--no-provision-keys', 'Do not auto-generate an owner key on pod creation') .option('--idp-issuer ', 'IdP issuer URL (defaults to server URL)') .option('--subdomains', 'Enable subdomain-based pods (XSS protection)') .option('--no-subdomains', 'Disable subdomain-based pods') @@ -218,6 +220,7 @@ program singleUser: config.singleUser, singleUserName: config.singleUserName, singleUserPassword: config.singleUserPassword, + provisionKeys: config.provisionKeys, public: config.public, readOnly: config.readOnly, liveReload: config.liveReload, diff --git a/docs/provision-keys.md b/docs/provision-keys.md new file mode 100644 index 00000000..ae53998f --- /dev/null +++ b/docs/provision-keys.md @@ -0,0 +1,157 @@ +# Owner-key provisioning (`--provision-keys`) + +Phase 1 of [#437](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437). Generates a Schnorr secp256k1 keypair on pod creation and writes it as a W3C [Controlled Identifiers v1.0 Multikey](https://www.w3.org/TR/cid-1.0/) document at `/private/privkey.jsonld`. + +The same key is intended to serve, over time, as: a Solid signing identity, a Nostr identity (same curve), and a `did:nostr:` DID controller (Phase 2). One CLI flag → pod-resident self-sovereign identity ready for both Solid and Nostr / agentic use cases. + +## Usage + +### Single-user mode (CLI flag) + +```bash +jss start --single-user --provision-keys +``` + +On first start the server creates the pod and writes the key file. Subsequent starts re-use the existing pod and don't touch the key (skip-if-exists semantics, like the rest of the seeded structure). + +### Multi-user mode (HTTP body field) + +```bash +curl -X POST http://localhost:4444/.pods \ + -H "Content-Type: application/json" \ + -d '{ "name": "alice", "provisionKeys": true }' +``` + +Response includes the public side of the key under `ownerKey`: + +```json +{ + "name": "alice", + "webId": "http://localhost:4444/alice/profile/card.jsonld#me", + "podUri": "http://localhost:4444/alice/", + "token": "…", + "ownerKey": { + "keyDocument": "http://localhost:4444/alice/private/privkey.jsonld", + "publicKeyMultibase": "fe70102…" + } +} +``` + +The secret is **never** echoed in the response — it lives only at the `keyDocument` path on disk, behind the pod's owner-only WAC. + +### Environment variable + +```bash +JSS_PROVISION_KEYS=true jss start --single-user +``` + +Same effect as the CLI flag; useful in containerised deployments. + +## What gets written + +`/private/privkey.jsonld`: + +```json +{ + "@context": "https://www.w3.org/ns/cid/v1", + "type": "Multikey", + "controller": "https://your.example/profile/card.jsonld#me", + "publicKeyMultibase": "fe7010287a1b…", + "secretKeyMultibase": "f81260a1b2c…" +} +``` + +- **`@context` + `type`** — W3C CID v1.0 conformant Multikey document. CID-aware tools recognise the shape natively. +- **`controller`** — In Phase 1 this is the pod owner's WebID. Phase 2 will swap in a `did:nostr:` controller once the resolver lands; the document is self-consistent at every phase. +- **`publicKeyMultibase`** — f-form (multibase `f` + multicodec `e701` for secp256k1-pub + parity byte + 32-byte x-only pubkey, all base16-lower). Round-trips through jss's existing `decodeFFormSecp256k1` decoder. +- **`secretKeyMultibase`** — f-form (multibase `f` + multicodec `8126` for secp256k1-priv + 32-byte secret scalar). The secret IS this scalar; no parity byte. + +The file mode is set to `0o600` on POSIX. WAC restricts HTTP access to the pod owner via `/private/.acl` (the standard private-folder ACL JSS writes anyway). + +## Incompatible with `--public` + +JSS refuses to provision keys when `--public` mode is on. `--public` tells JSS to skip WAC entirely and grant unauthenticated access to every resource — including `/private/privkey.jsonld`. Combined with `--provision-keys`, that would write a plaintext secret straight to a publicly-readable URL. + +The two flags are explicitly incompatible: + +- **At server start**: `jss start --single-user --provision-keys --public` throws at server-create time with a clear error. The server doesn't start. +- **At pod creation over HTTP**: `POST /.pods` with `provisionKeys: true` returns **400 Bad Request** when the server is in `--public` mode. + +If you genuinely want both behaviours, you don't actually want both: either pick `--public` (no auth, no secrets on disk) or pick `--provision-keys` (WAC-protected secrets). There's no middle ground that's safe. + +## Threat model and protection + +The web user (pod owner) reads the key over HTTP, authenticated against their WebID. WAC checks the request and serves the file: + +```bash +curl -H "Authorization: Bearer " \ + http://localhost:4444/alice/private/privkey.jsonld +``` + +That path is fully protected by ACL. **What WAC cannot protect against is filesystem access.** Anyone with read on the data directory bypasses WAC entirely: + +- `root` on the host +- Container layer dumps and exfiltrated images +- Backup tapes / snapshots that copy file contents +- Other unix users on a shared box (mitigated by the `0o600` mode) + +For any pod that matters, layer in **filesystem-level protection**: + +| Mechanism | What it adds | +|---|---| +| Full-disk encryption (LUKS / FileVault / BitLocker) | Protects offline copies of the disk | +| Encrypted volume / per-directory encryption | Protects against host-level snapshots | +| OS keyring (gnome-keyring, macOS Keychain) | Out-of-process secret storage; bigger lift | +| Restrictive `umask` + container user namespacing | Stops other users on a shared host | +| Filesystem ACLs (`setfacl`) | Fine-grained per-user limits | + +Phase 4 of [#437](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437) will add encrypted-at-rest and hardware-wallet support. Until then, treat the file as a credential and protect it accordingly. + +## Credible exit + +Phase 1 ships the simplest workable model: plaintext key on disk, owner-only WAC, `0o600`. This is intentional — it lets you start now and migrate later without losing the underlying identity: + +| Phase | What changes | What stays | +|---|---|---| +| 1 (now) | plaintext on disk, WAC + 0o600 | the keypair, the WebID | +| 3 | optional `--key-passphrase` wraps the secret with scrypt+aesgcm | the keypair | +| 4 | hardware wallet / KMS / encrypted at rest | the keypair | + +Each upgrade is a wrapper around the same secret material. You're not picking a final answer when you turn `--provision-keys` on — you're picking a starting point that can move with your security posture. ActivityPub took the same path: instances managed keys themselves at first, then later layered hardware-backed signing on top once the ecosystem matured. + +## Backup + +This is not optional. + +```bash +# Authenticate as owner via HTTP (preferred — works even on a remote host). +# For a single-user / root pod the key is at /private/privkey.jsonld; +# for a named pod (multi-user) it's at //private/privkey.jsonld. +curl -H "Authorization: Bearer " \ + http://your.example/private/privkey.jsonld \ + -o pod-key-backup.jsonld +# named-pod variant: +curl -H "Authorization: Bearer " \ + http://your.example/alice/private/privkey.jsonld \ + -o pod-key-backup.jsonld +chmod 600 pod-key-backup.jsonld + +# Or copy from disk if you have local access (preserves perms). +# Root pod (single-user, default since #348): +cp -p /private/privkey.jsonld pod-key-backup.jsonld +# Named pod (multi-user, or single-user with --single-user-name=alice): +cp -p /alice/private/privkey.jsonld pod-key-backup.jsonld +``` + +Store the backup somewhere that survives the pod's host: another machine, encrypted cloud storage, a hardware token, a sealed envelope in a safe — whatever you'd use for an SSH key you actually care about. Losing this file means losing this identity permanently. There is no recovery flow in Phase 1. + +## Why opt-in by default + +Keys-on-disk is a real security tradeoff. Defaulting `--provision-keys` on would mean every `jss start --single-user` ships a plaintext secret to disk silently — a bad surprise for an operator who only meant to spin up a server. Opt-in keeps the choice visible. (See [#437 design resolution #6](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437).) + +## See also + +- [#437](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437) — the umbrella issue and design resolutions +- [W3C Controlled Identifiers v1.0 (REC)](https://www.w3.org/TR/cid-1.0/) +- [`src/auth/nostr-keys.js`](../src/auth/nostr-keys.js) — the existing f-form Multikey decoder this code aligns with +- [BIP-340 (Schnorr signatures over secp256k1)](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) diff --git a/src/config.js b/src/config.js index a0d2225d..d2dd298f 100644 --- a/src/config.js +++ b/src/config.js @@ -92,6 +92,12 @@ export const defaults = { // is not yet loggable until a password is set). singleUserPassword: null, + // Provision a Schnorr secp256k1 owner key on pod creation, written + // to /private/privkey.jsonld in W3C CID v1.0 Multikey format + // (Phase 1 of #437). Off by default — keys-on-disk is a security + // tradeoff and we want operators to opt in deliberately. + provisionKeys: false, + // WebID-TLS client certificate authentication webidTls: false, @@ -175,6 +181,7 @@ const envMap = { JSS_SINGLE_USER: 'singleUser', JSS_SINGLE_USER_NAME: 'singleUserName', JSS_SINGLE_USER_PASSWORD: 'singleUserPassword', + JSS_PROVISION_KEYS: 'provisionKeys', JSS_WEBID_TLS: 'webidTls', JSS_DEFAULT_QUOTA: 'defaultQuota', JSS_PUBLIC: 'public', @@ -228,6 +235,7 @@ const BOOLEAN_KEYS = new Set([ 'inviteOnly', 'multiuser', 'singleUser', + 'provisionKeys', 'webidTls', 'public', 'readOnly', diff --git a/src/handlers/container.js b/src/handlers/container.js index 698ad8e0..8fb4d5a7 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -4,6 +4,7 @@ import { getAllHeaders } from '../ldp/headers.js'; import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js'; import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js'; import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId } from '../wac/parser.js'; +import { provisionOwnerKey, assertProvisionKeysCompatible } from '../keys/provision.js'; import { createToken } from '../auth/token.js'; import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js'; import { emitChange } from '../notifications/events.js'; @@ -165,8 +166,19 @@ export async function handlePost(request, reply) { * @param {string} podUri - Pod root URI (e.g., https://alice.example.com/ or https://example.com/alice/) * @param {string} issuer - OIDC issuer URI * @param {number} defaultQuota - Default storage quota in bytes (optional) + * @param {object} [options] + * @param {boolean} [options.provisionKeys=false] - When true, generate a + * Schnorr secp256k1 keypair and write it to `/private/privkey.jsonld` + * in W3C CID v1.0 Multikey format. Phase 1 of #437. The secret lands on + * disk in plaintext under owner-only WAC + file mode 0600 — operators + * should add filesystem-level protection (FDE / OS keyring) for any pod + * that matters. + * @returns {Promise<{ podPath, podUri, ownerKey?: { document, publicHex, secretHex, publicMultibase } }>} + * When `provisionKeys` is true, the return value includes the freshly + * minted key material so the caller can surface the public side in CLI + * output (the secret should NOT be displayed or logged). */ -export async function createPodStructure(name, webId, podUri, issuer, defaultQuota = 0) { +export async function createPodStructure(name, webId, podUri, issuer, defaultQuota = 0, options = {}) { const podPath = `/${name}/`; // Create pod directory structure @@ -236,7 +248,31 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo await initializeQuota(name, defaultQuota); } - return { podPath, podUri }; + // Optional: provision a Schnorr secp256k1 owner key in /private/. + // Phase 1 of #437. See src/keys/provision.js for the design notes. + // Throw on write failure so the caller's cleanup path runs and the + // pod isn't left with a phantom `ownerKey` in the response that + // doesn't correspond to any on-disk file. + // + // Strict `=== true` (not just truthy) so a misconfigured caller + // passing `'true'` / `1` / etc. doesn't silently activate. Matches + // handleCreatePod's HTTP-side check on the body field. + let ownerKey; + if (options.provisionKeys === true) { + ownerKey = provisionOwnerKey({ controllerWebId: webId }); + const ok = await storage.write( + `${podPath}private/privkey.jsonld`, + JSON.stringify(ownerKey.document, null, 2), + { mode: 0o600 } + ); + if (!ok) { + throw new Error( + `Failed to write owner key file at ${podPath}private/privkey.jsonld` + ); + } + } + + return { podPath, podUri, ownerKey }; } /** @@ -260,7 +296,7 @@ export async function handleCreatePod(request, reply) { return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' }); } - const { name, email, password } = request.body || {}; + const { name, email, password, provisionKeys } = request.body || {}; const idpEnabled = request.idpEnabled; if (!name || typeof name !== 'string') { @@ -282,6 +318,25 @@ export async function handleCreatePod(request, reply) { return reply.code(400).send({ error: 'Invalid pod name. Use alphanumeric, dash, or underscore only.' }); } + // Refuse provisionKeys + --public: WAC would be bypassed, exposing the + // freshly written secret to anyone. Use the same assertion helper as + // createServer's startup-time check so the error message stays in + // one place — converted to a 400 here because we're in an HTTP + // request context, not the constructor. + if (provisionKeys === true) { + try { + assertProvisionKeysCompatible({ + provisionKeys: true, + isPublic: !!request.config?.public + }); + } catch (err) { + return reply.code(400).send({ + error: 'provisionKeys cannot be used in --public mode', + message: err.message + }); + } + } + const podPath = `/${name}/`; // Check if pod already exists @@ -310,9 +365,14 @@ export async function handleCreatePod(request, reply) { // Issuer needs trailing slash for CTH compatibility const issuer = baseUri + '/'; + let podCreation; try { - // Use shared pod creation function - await createPodStructure(name, webId, podUri, issuer); + // Use shared pod creation function. Coerce provisionKeys to a + // strict boolean so a JSON `null` / missing value defaults to off. + podCreation = await createPodStructure( + name, webId, podUri, issuer, 0, + { provisionKeys: provisionKeys === true } + ); } catch (err) { console.error('Pod creation error:', err); // Cleanup on failure @@ -326,6 +386,16 @@ export async function handleCreatePod(request, reply) { Object.entries(headers).forEach(([k, v]) => reply.header(k, v)); + // Surface a summary of any provisioned key so callers can display + // the public side. The secret is NEVER echoed in the response — + // it lives only on disk under the pod's owner-only ACL. + const keyInfo = podCreation?.ownerKey + ? { + keyDocument: `${podUri}private/privkey.jsonld`, + publicKeyMultibase: podCreation.ownerKey.publicMultibase + } + : null; + // If IdP is enabled, create account and return token + login URL if (idpEnabled) { try { @@ -340,6 +410,7 @@ export async function handleCreatePod(request, reply) { token, idpIssuer: issuer, loginUrl: `${baseUri}/idp/auth`, + ...(keyInfo && { ownerKey: keyInfo }) }); } catch (err) { console.error('Account creation error:', err); @@ -356,6 +427,7 @@ export async function handleCreatePod(request, reply) { name, webId, podUri, - token + token, + ...(keyInfo && { ownerKey: keyInfo }) }); } diff --git a/src/keys/provision.js b/src/keys/provision.js new file mode 100644 index 00000000..2cd236a6 --- /dev/null +++ b/src/keys/provision.js @@ -0,0 +1,157 @@ +/** + * Owner-key provisioning for new pods. + * + * Generates a Schnorr secp256k1 keypair and serialises it as a + * W3C Controlled Identifiers (CID) v1.0 Multikey document. The same + * curve serves as a Solid signing identity, a Nostr identity, and a + * future did:nostr DID controller (Phase 2). + * + * Phase 1 of #437 — see the issue for design resolutions: + * - controller in Phase 1: the pod owner's WebID (did:nostr in Phase 2) + * - secret key on disk: plaintext, owner-only ACL, file mode 0600; + * filesystem-level protection (FDE / LUKS / OS keyring) recommended; + * wrapped-secret support deferred to Phase 3 + * - encoding: multibase `f` (base16-lower) + multicodec; matches the + * style already used in src/auth/nostr-keys.js for did:nostr + * Multikey verification methods. Pure hex with ~5 bytes of + * self-describing metadata at the front, no new deps. + * - no `nostr` extension block: bech32 npub is a one-line derivation + * in any Nostr-aware tool, not worth the additional secret-on-disk + * surface area or a new dependency. + */ + +import { schnorr } from '@noble/curves/secp256k1'; + +/** + * Multicodec varints (lower-hex). The CCG / W3C CID v1.0 Multikey + * registry uses these to identify the algorithm of a key blob inside + * a multibase-encoded value. + * secp256k1-pub → 0xe7 → varint "e701" + * secp256k1-priv → 0x1301 → varint "8126" + */ +const MULTICODEC_SECP256K1_PUB_HEX = 'e701'; +const MULTICODEC_SECP256K1_PRIV_HEX = '8126'; + +/** + * Compressed-SEC1 parity byte for the *even-y* canonical point. BIP-340 + * (Schnorr / Nostr) keys are x-only and pick the even-y point at each + * x; pairing the f-form prefix with `02` makes the Multikey value + * round-trip with src/auth/nostr-keys.js's decoder. + */ +const EVEN_Y_PARITY_HEX = '02'; + +/** + * Generate a fresh secp256k1 keypair suitable for Schnorr signing. + * + * @returns {{ secretHex: string, publicHex: string }} + * `secretHex` — 32-byte secret scalar, lower-hex. + * `publicHex` — 32-byte x-only Schnorr pubkey, lower-hex (BIP-340). + */ +export function generateOwnerKeypair() { + const secretBytes = schnorr.utils.randomPrivateKey(); + const publicBytes = schnorr.getPublicKey(secretBytes); + return { + secretHex: bytesToHex(secretBytes), + publicHex: bytesToHex(publicBytes) + }; +} + +/** + * Encode a 32-byte hex value as an f-form Multikey value. + * For pub keys we prepend the parity byte (BIP-340 convention: 02); + * for priv keys the secret scalar IS the 32-byte payload — no parity. + */ +export function publicKeyMultibase(publicHex) { + if (!/^[0-9a-f]{64}$/.test(publicHex)) { + throw new Error('publicKeyMultibase: expected 64-char lower-hex pubkey'); + } + return 'f' + MULTICODEC_SECP256K1_PUB_HEX + EVEN_Y_PARITY_HEX + publicHex; +} + +export function secretKeyMultibase(secretHex) { + if (!/^[0-9a-f]{64}$/.test(secretHex)) { + throw new Error('secretKeyMultibase: expected 64-char lower-hex secret'); + } + return 'f' + MULTICODEC_SECP256K1_PRIV_HEX + secretHex; +} + +/** + * Build the W3C CID v1.0 Multikey JSON-LD document for a fresh pod + * owner key. The `controller` is the pod owner's WebID — Phase 1 + * keeps the document self-consistent at every phase (Phase 2 will + * swap in a `did:nostr:` controller once the resolver lands). + * + * @param {object} args + * @param {string} args.controllerWebId - Absolute owner WebID URI. + * @param {string} args.publicHex - 32-byte x-only Schnorr pubkey hex. + * @param {string} args.secretHex - 32-byte secret scalar hex. + * @returns {object} JSON-LD Multikey document, ready for `JSON.stringify`. + */ +export function buildOwnerKeyDocument({ controllerWebId, publicHex, secretHex }) { + if (typeof controllerWebId !== 'string' || !controllerWebId) { + throw new Error('buildOwnerKeyDocument: controllerWebId required'); + } + return { + '@context': 'https://www.w3.org/ns/cid/v1', + type: 'Multikey', + controller: controllerWebId, + publicKeyMultibase: publicKeyMultibase(publicHex), + secretKeyMultibase: secretKeyMultibase(secretHex) + }; +} + +/** + * One-shot helper: generate a fresh keypair and produce both the + * Multikey document and the raw key material (for log lines / CLI + * output that wants to display the pubkey). + * + * The returned `secretHex` should be considered sensitive and not + * logged; the public `multibase` IS safe to print. + */ +export function provisionOwnerKey({ controllerWebId }) { + const { publicHex, secretHex } = generateOwnerKeypair(); + const document = buildOwnerKeyDocument({ controllerWebId, publicHex, secretHex }); + return { + document, + publicHex, + secretHex, + publicMultibase: document.publicKeyMultibase + }; +} + +/** + * Refuse to provision keys when WAC is being bypassed. + * + * `--public` (and `request.config.public`) tells JSS to skip WAC and + * grant unauthenticated access to everything. Combined with + * `--provision-keys`, it would mean a plaintext secret at + * `/private/privkey.jsonld` is readable by anyone over HTTP — the + * exact opposite of what the seeded owner-only ACL is supposed to do. + * + * Throws a clear error so the operator hits the contradiction at + * startup (or pod-creation) rather than discovering it by reading + * their own server logs after a key leak. + * + * @param {object} args + * @param {boolean} args.provisionKeys + * @param {boolean} args.isPublic - jss `--public` mode + * @throws {Error} when both flags are true + */ +export function assertProvisionKeysCompatible({ provisionKeys, isPublic }) { + if (provisionKeys && isPublic) { + throw new Error( + '--provision-keys cannot be combined with --public. --public bypasses ' + + 'WAC, which would make /private/privkey.jsonld readable by anyone over ' + + 'HTTP. Use --provision-keys with WAC enforcement (the default), or drop ' + + '--public.' + ); + } +} + +function bytesToHex(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += bytes[i].toString(16).padStart(2, '0'); + } + return s; +} diff --git a/src/server.js b/src/server.js index 47f04ed4..eee613db 100644 --- a/src/server.js +++ b/src/server.js @@ -29,6 +29,7 @@ import { tunnelPlugin } from './tunnel/index.js'; import { terminalPlugin } from './terminal/index.js'; import { registerErrorHandler } from './utils/error-handler.js'; import { seedServerRoot } from './ui/server-root.js'; +import { assertProvisionKeysCompatible } from './keys/provision.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -137,6 +138,26 @@ export function createServer(options = {}) { const liveReloadEnabled = options.liveReload ?? false; // MongoDB-backed /db/ route is OFF by default const mongoEnabled = options.mongo ?? false; + // Provision a Schnorr secp256k1 owner key in /private/privkey.jsonld + // when a single-user pod is first created. Phase 1 of #437. Off by + // default: keys-on-disk is a real security tradeoff, opt-in keeps + // the choice visible to the operator. + // + // Refuse the --provision-keys + --public combination at server-create + // time so the operator hits the contradiction immediately rather than + // by reading a leaked key from logs / the public web. See #442 review. + // + // Strict `=== true` (not `?? false`) coerces a misconfigured truthy + // non-boolean (e.g. JSON config / env coercion handing in `'true'` + // as a string) to false at the boundary. Without this, the root-pod + // branch's `if (provisionKeysEnabled)` would activate while the + // named-pod path's strict check downstream would not, leaving the + // two pod shapes behaving differently for the same input. + const provisionKeysEnabled = options.provisionKeys === true; + assertProvisionKeysCompatible({ + provisionKeys: provisionKeysEnabled, + isPublic: !!options.public + }); const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017'; const mongoDatabase = options.mongoDatabase ?? 'solid'; // HTTP 402 paid /pay/ routes are OFF by default @@ -840,14 +861,37 @@ export function createServer(options = {}) { if (!profileExists) { fastify.log.info(`Creating single-user pod at ${podUri}...`); + let creation; if (isRootPod) { // Root-level pod - create structure directly at / - await createRootPodStructure(webId, podUri, issuer, displayName); + creation = await createRootPodStructure(webId, podUri, issuer, displayName); } else { // Named pod at /{name}/ - await createPodStructure(singleUserName, webId, podUri, issuer, defaultQuota); + creation = await createPodStructure( + singleUserName, webId, podUri, issuer, defaultQuota, + { provisionKeys: provisionKeysEnabled } + ); } fastify.log.info(`Single-user pod created at ${podUri}`); + + // Surface the public side of any provisioned owner key, plus + // a prominent backup reminder. The secret is NOT logged — it + // lives on disk only, under /private/privkey.jsonld with + // owner-only WAC and file mode 0o600. + if (creation?.ownerKey) { + // `podUri` already includes the trailing slash + any pod + // name segment, so the same expression covers root and + // named single-user pods. + const keyPath = `${podUri}private/privkey.jsonld`; + fastify.log.info(`Provisioned Schnorr secp256k1 owner key`); + fastify.log.info(` Public key file: ${keyPath}`); + fastify.log.info(` publicKeyMultibase: ${creation.ownerKey.publicMultibase}`); + fastify.log.warn( + `BACK UP ${keyPath} — losing this file means losing this identity. ` + + 'Filesystem reads bypass WAC; use FDE / OS keyring / restrictive umask ' + + 'for any pod that matters. See docs/provision-keys.md.' + ); + } } // Seed an IDP account so the operator can actually log in. Without @@ -980,11 +1024,19 @@ export function createServer(options = {}) { } /** - * Create root-level pod structure (for single-user mode with pod at /) + * Create root-level pod structure (for single-user mode with pod at /). + * When --provision-keys is set, returns `{ ownerKey }` so the caller + * can surface the public side in the startup banner. The returned + * `ownerKey` includes secretHex and secretKeyMultibase — needed by + * tests, present in case a future caller needs to perform a one-shot + * sign before the file is read back via WAC. **Callers must not log + * the secret.** The secret's only durable home is the on-disk file + * under /private/ (mode 0o600, owner-only WAC). */ async function createRootPodStructure(webId, podUri, issuer, displayName) { const { generateProfile, generatePreferences, generateTypeIndex, serialize } = await import('./webid/profile.js'); const { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId } = await import('./wac/parser.js'); + const { provisionOwnerKey } = await import('./keys/provision.js'); // Create directories at root await storage.createContainer('/inbox/'); @@ -1038,7 +1090,27 @@ export function createServer(options = {}) { const profileAcl = generatePublicFolderAcl('./', owner('profile/')); await storage.write('/profile/.acl', serializeAcl(profileAcl)); + // Optional: provision a Schnorr secp256k1 owner key in /private/. + // Phase 1 of #437. See src/keys/provision.js for the design notes. + // Throw on write failure so single-user startup fails loud rather + // than logging "Provisioned …" against a missing on-disk file. + let ownerKey; + if (provisionKeysEnabled) { + ownerKey = provisionOwnerKey({ controllerWebId: webId }); + const ok = await storage.write( + '/private/privkey.jsonld', + JSON.stringify(ownerKey.document, null, 2), + { mode: 0o600 } + ); + if (!ok) { + throw new Error( + 'Failed to write owner key file at /private/privkey.jsonld' + ); + } + } + // Note: Quota not initialized for root-level pods (no user directory) + return { ownerKey }; } // Start file watcher for live reload (watches filesystem for external changes) diff --git a/src/storage/filesystem.js b/src/storage/filesystem.js index 907da0a4..e478d100 100644 --- a/src/storage/filesystem.js +++ b/src/storage/filesystem.js @@ -74,18 +74,57 @@ export function createReadStream(urlPath, options = {}) { } /** - * Write resource content - * @param {string} urlPath - * @param {Buffer | string} content - * @returns {Promise} + * Write resource content. + * + * @param {string} urlPath - URL path of the resource being written + * (translated to a filesystem path internally). + * @param {Buffer | string} content - Bytes / text to write. Replaces + * the file if it already exists. + * @param {object} [options] + * @param {number} [options.mode] - POSIX file mode (e.g. `0o600`) to + * apply to the created file. Passed to `fs.writeFile` at create + * time so the file is never visible to other unix users with a + * looser default; an additional `chmod` runs afterward to tighten + * the file when overwriting an existing path that was created with + * a wider mode. On Windows, NTFS approximates POSIX modes coarsely + * (effectively read-only flag based on owner perms) — `chmod` is + * still attempted there but should not be relied on for + * cross-platform secrecy. See #437 for the secret-material use case. + * @returns {Promise} `true` on success, `false` on write + * failure (chmod failures are logged but do not fail the write). */ -export async function write(urlPath, content) { +export async function write(urlPath, content, options = {}) { const filePath = urlToPath(urlPath); try { // Ensure parent directory exists await fs.ensureDir(path.dirname(filePath)); - await fs.writeFile(filePath, content); + + // Pass `mode` to writeFile so the file is *created* with the + // requested permissions, closing the race window where another + // local process could read a freshly created secret-material + // file before a follow-up `chmod` ran. (Node only honours `mode` + // at create time, never on overwrite.) + if (typeof options.mode === 'number') { + await fs.writeFile(filePath, content, { mode: options.mode }); + } else { + await fs.writeFile(filePath, content); + } + + // Belt-and-braces: when overwriting an existing file, writeFile + // does NOT change the existing mode — apply chmod so a stale 0644 + // file gets tightened to 0600 on subsequent writes. Logged but + // non-fatal: chmod is attempted on every platform (incl. Windows, + // where NTFS approximates POSIX modes coarsely) but callers that + // care about strict permissions should also rely on filesystem- + // level protection (FDE / OS keyring / container user namespacing). + if (typeof options.mode === 'number') { + try { + await fs.chmod(filePath, options.mode); + } catch (chmodErr) { + console.warn(`chmod ${options.mode.toString(8)} on ${filePath} failed:`, chmodErr.message); + } + } return true; } catch (err) { console.error('Write error:', err); diff --git a/test/keys-provision-integration.test.js b/test/keys-provision-integration.test.js new file mode 100644 index 00000000..b567695c --- /dev/null +++ b/test/keys-provision-integration.test.js @@ -0,0 +1,266 @@ +/** + * Integration tests for the wired --provision-keys / `provisionKeys: true` + * pod-creation flow (Phase 1 of #437). + * + * Covers: + * - POST /.pods with provisionKeys: true writes a Multikey doc + * to /private/privkey.jsonld + * - The seeded ACL on /private/ keeps the file private (401 unauth, + * 200 with the owner token) — defence-in-depth alongside the + * 0o600 file mode set by storage.write. + * - The HTTP response surfaces the public side (publicKeyMultibase) + * and the file URL, but never echoes the secret. + * - Default behaviour (no flag) does NOT write the file — opt-in + * stays opt-in. + * - createPodStructure called directly with provisionKeys returns + * the freshly-minted key material so a CLI caller can display it. + * - createPodStructure with provisionKeys=false returns no ownerKey. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs-extra'; +import path from 'path'; +import { + startTestServer, + stopTestServer, + request, + assertStatus, + getBaseUrl +} from './helpers.js'; + +// Local token stash — the createTestPod helper hardwires its own +// fetch call so we can't use it for the provisionKeys-true variant; +// just keep the token returned by our manual POST around for the +// authenticated WAC test below. +let ownerToken = null; +import { createPodStructure } from '../src/handlers/container.js'; +import { decodeFFormSecp256k1 } from '../src/auth/nostr-keys.js'; + +describe('POST /.pods — provisionKeys: true (Phase 1 of #437)', () => { + before(async () => { + await startTestServer(); + }); + after(async () => { + await stopTestServer(); + }); + + it('creates the pod and writes /private/privkey.jsonld', async () => { + const res = await fetch(`${getBaseUrl()}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'keyowner', provisionKeys: true }) + }); + assertStatus(res, 201); + const body = await res.json(); + ownerToken = body.token; + + // Response surfaces the public side, never the secret. + assert.ok(body.ownerKey, 'response should include ownerKey summary'); + assert.strictEqual(body.ownerKey.keyDocument, `${body.podUri}private/privkey.jsonld`); + assert.match(body.ownerKey.publicKeyMultibase, /^fe70102[0-9a-f]{64}$/, + 'publicKeyMultibase should be the f-form secp256k1-pub Multikey'); + // No secret key on the wire under any field name. + const flat = JSON.stringify(body); + assert.doesNotMatch(flat, /secretKey|secret_key|nsec|privateKey/i, + 'response must not echo any secret material'); + + // The file exists on disk and parses as the expected Multikey doc. + const filePath = path.join('./data', 'keyowner', 'private', 'privkey.jsonld'); + assert.ok(await fs.pathExists(filePath), '/private/privkey.jsonld must exist'); + const doc = JSON.parse(await fs.readFile(filePath, 'utf8')); + assert.strictEqual(doc['@context'], 'https://www.w3.org/ns/cid/v1'); + assert.strictEqual(doc.type, 'Multikey'); + assert.strictEqual(doc.controller, body.webId, + 'Phase 1 controller is the pod owner WebID (did:nostr lands in Phase 2)'); + assert.match(doc.publicKeyMultibase, /^fe70102[0-9a-f]{64}$/); + assert.match(doc.secretKeyMultibase, /^f8126[0-9a-f]{64}$/); + // Round-trip the public side through jss's existing decoder. + const xonly = decodeFFormSecp256k1(doc.publicKeyMultibase); + assert.match(xonly, /^[0-9a-f]{64}$/); + }); + + it('writes the secret with file mode 0o600 (POSIX defence-in-depth)', { skip: process.platform === 'win32' }, async () => { + const filePath = path.join('./data', 'keyowner', 'private', 'privkey.jsonld'); + const stat = await fs.stat(filePath); + // Strip the file-type bits (0o170000) and assert the perm bits. + const mode = stat.mode & 0o777; + assert.strictEqual(mode, 0o600, + `Expected 0o600 on the secret key file, got 0o${mode.toString(8)}`); + }); + + it('blocks unauthenticated reads of the secret key (WAC)', async () => { + const res = await request('/keyowner/private/privkey.jsonld'); + assertStatus(res, 401); + }); + + it('serves the secret to the authenticated owner (WAC)', async () => { + assert.ok(ownerToken, 'pod owner token captured from the create response'); + const res = await fetch(`${getBaseUrl()}/keyowner/private/privkey.jsonld`, { + headers: { Authorization: `Bearer ${ownerToken}` } + }); + assertStatus(res, 200); + const doc = await res.json(); + // The web user (pod owner) reads via HTTP/WAC — file mode 0o600 + // restricts unix-user-on-the-box access only, never this path. + assert.strictEqual(doc.type, 'Multikey'); + assert.match(doc.secretKeyMultibase, /^f8126[0-9a-f]{64}$/); + }); + + it('does NOT write a key file when provisionKeys is omitted (opt-in)', async () => { + const res = await fetch(`${getBaseUrl()}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'nokeys' }) + }); + assertStatus(res, 201); + const body = await res.json(); + assert.strictEqual(body.ownerKey, undefined, + 'opt-in: response must not include ownerKey when flag is omitted'); + const filePath = path.join('./data', 'nokeys', 'private', 'privkey.jsonld'); + assert.strictEqual(await fs.pathExists(filePath), false, + 'opt-in: file must not be written when flag is omitted'); + }); + + it('does NOT write a key file when provisionKeys is false (explicit opt-out)', async () => { + const res = await fetch(`${getBaseUrl()}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'nokeys2', provisionKeys: false }) + }); + assertStatus(res, 201); + const body = await res.json(); + assert.strictEqual(body.ownerKey, undefined); + const filePath = path.join('./data', 'nokeys2', 'private', 'privkey.jsonld'); + assert.strictEqual(await fs.pathExists(filePath), false); + }); + + it('does NOT trigger on truthy non-true values (e.g. string "true")', async () => { + // Defensive: only literal `true` activates the flag — keeps an + // accidentally-string-coerced value from silently turning it on. + const res = await fetch(`${getBaseUrl()}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'stringtrue', provisionKeys: 'true' }) + }); + assertStatus(res, 201); + const body = await res.json(); + assert.strictEqual(body.ownerKey, undefined, + 'string "true" must not trigger key provisioning'); + const filePath = path.join('./data', 'stringtrue', 'private', 'privkey.jsonld'); + assert.strictEqual(await fs.pathExists(filePath), false); + }); +}); + +describe('POST /.pods — provisionKeys + --public (footgun guard)', () => { + // --public mode bypasses WAC: every resource becomes publicly + // readable. Combined with provisionKeys, /private/privkey.jsonld + // would be the world's worst secret store. Refuse the combination + // at request time with 400 so the operator hits the contradiction + // immediately. See #442 review. + let server; + let baseUrl; + let savedDataRoot; + const DATA_DIR = './test-data-provision-public'; + + before(async () => { + savedDataRoot = process.env.DATA_ROOT; + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + const { createServer } = await import('../src/server.js'); + server = createServer({ + logger: false, + forceCloseConnections: true, + root: DATA_DIR, + public: true + }); + await server.listen({ port: 0, host: '127.0.0.1' }); + baseUrl = `http://127.0.0.1:${server.server.address().port}`; + }); + after(async () => { + await server.close(); + await fs.remove(DATA_DIR); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; + }); + + it('rejects POST /.pods with provisionKeys: true on a --public server (400)', async () => { + const res = await fetch(`${baseUrl}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'shouldnotexist', provisionKeys: true }) + }); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.match(body.message || body.error || '', /public/i, + 'error must explain why provisionKeys is rejected'); + // Pod must not have been created. + assert.strictEqual(await fs.pathExists(`${DATA_DIR}/shouldnotexist/`), false); + }); + + it('still allows POST /.pods without provisionKeys on a --public server', async () => { + const res = await fetch(`${baseUrl}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'publicpod' }) + }); + assert.strictEqual(res.status, 201); + }); +}); + +describe('createServer — provisionKeys + --public refused at start', () => { + it('throws synchronously when both options are set', async () => { + const { createServer } = await import('../src/server.js'); + assert.throws( + () => createServer({ logger: false, provisionKeys: true, public: true }), + /cannot be combined with --public/ + ); + }); +}); + +describe('createPodStructure — provisionKeys option (direct call)', () => { + before(async () => { + await startTestServer(); + }); + after(async () => { + await stopTestServer(); + }); + + it('returns ownerKey when provisionKeys is true', async () => { + const podUri = 'http://127.0.0.1:0/direct1/'; + const webId = `${podUri}profile/card.jsonld#me`; + const result = await createPodStructure( + 'direct1', webId, podUri, podUri.replace(/\/$/, '/'), 0, + { provisionKeys: true } + ); + assert.ok(result.ownerKey, 'createPodStructure must return ownerKey when provisionKeys is true'); + assert.match(result.ownerKey.publicHex, /^[0-9a-f]{64}$/); + assert.match(result.ownerKey.secretHex, /^[0-9a-f]{64}$/); + assert.strictEqual(result.ownerKey.publicMultibase, result.ownerKey.document.publicKeyMultibase); + assert.strictEqual(result.ownerKey.document.controller, webId); + }); + + it('returns no ownerKey when the option is omitted', async () => { + const podUri = 'http://127.0.0.1:0/direct2/'; + const webId = `${podUri}profile/card.jsonld#me`; + const result = await createPodStructure( + 'direct2', webId, podUri, podUri.replace(/\/$/, '/'), 0 + ); + assert.strictEqual(result.ownerKey, undefined); + }); + + it('requires strict boolean true (a truthy non-boolean does not trigger)', async () => { + // Defensive: matches the HTTP-side check on the request body so a + // misconfigured caller passing 'true' / 1 / etc. through the + // direct API doesn't silently provision a plaintext secret. + const podUri = 'http://127.0.0.1:0/direct3/'; + const webId = `${podUri}profile/card.jsonld#me`; + const result = await createPodStructure( + 'direct3', webId, podUri, podUri.replace(/\/$/, '/'), 0, + { provisionKeys: 'true' } // intentionally string, not boolean + ); + assert.strictEqual(result.ownerKey, undefined, + 'string "true" must not activate provisioning at the direct entry'); + assert.strictEqual(await fs.pathExists('./data/direct3/private/privkey.jsonld'), false); + }); +}); diff --git a/test/keys-provision.test.js b/test/keys-provision.test.js new file mode 100644 index 00000000..f4997253 --- /dev/null +++ b/test/keys-provision.test.js @@ -0,0 +1,172 @@ +/** + * Unit tests for owner-key provisioning (Phase 1 of #437). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { schnorr } from '@noble/curves/secp256k1'; +import { + generateOwnerKeypair, + publicKeyMultibase, + secretKeyMultibase, + buildOwnerKeyDocument, + provisionOwnerKey, + assertProvisionKeysCompatible +} from '../src/keys/provision.js'; +import { decodeFFormSecp256k1 } from '../src/auth/nostr-keys.js'; + +describe('generateOwnerKeypair', () => { + it('produces a 32-byte x-only pubkey and a 32-byte secret', () => { + const { publicHex, secretHex } = generateOwnerKeypair(); + assert.match(publicHex, /^[0-9a-f]{64}$/); + assert.match(secretHex, /^[0-9a-f]{64}$/); + }); + + it('produces a working Schnorr keypair (sign + verify)', () => { + const { publicHex, secretHex } = generateOwnerKeypair(); + const message = new TextEncoder().encode('jss provision-keys roundtrip'); + const sig = schnorr.sign(message, hexToBytes(secretHex)); + assert.strictEqual( + schnorr.verify(sig, message, hexToBytes(publicHex)), + true, + 'signature must verify under the generated pubkey' + ); + }); + + it('produces a different keypair on every call', () => { + const a = generateOwnerKeypair(); + const b = generateOwnerKeypair(); + assert.notStrictEqual(a.publicHex, b.publicHex); + assert.notStrictEqual(a.secretHex, b.secretHex); + }); +}); + +describe('publicKeyMultibase / secretKeyMultibase', () => { + // Fixed test vectors so the encoding format is pinned (any change to + // multicodec/multibase would shift these values and fail the assertion). + const publicHex = '87a1c6f0e9b3d2456789abcdef0123456789abcdef0123456789abcdef012345'; + const secretHex = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + it('encodes a public key as f + e701 + 02 + 64-hex-pubkey', () => { + const mb = publicKeyMultibase(publicHex); + // f (multibase) + e701 (multicodec varint for secp256k1-pub) + + // 02 (BIP-340 even-y parity) + 64-char pubkey hex + assert.strictEqual(mb, 'fe70102' + publicHex); + }); + + it('encodes a secret key as f + 8126 + 64-hex-secret', () => { + const mb = secretKeyMultibase(secretHex); + // f (multibase) + 8126 (multicodec varint for secp256k1-priv) + + // 64-char secret hex (no parity byte — secret IS the scalar) + assert.strictEqual(mb, 'f8126' + secretHex); + }); + + it('round-trips through src/auth/nostr-keys decoder for the public side', () => { + // The Multikey value we write must be readable by jss's existing + // f-form decoder (used by the did:nostr verifier). This pins the + // shape so a future change to either side fails loud. + const mb = publicKeyMultibase(publicHex); + const decoded = decodeFFormSecp256k1(mb); + assert.strictEqual(decoded, publicHex); + }); + + it('rejects non-hex / wrong-length inputs', () => { + assert.throws(() => publicKeyMultibase('not-hex'), /64-char lower-hex/); + assert.throws(() => publicKeyMultibase(publicHex.slice(0, 63)), /64-char/); + assert.throws(() => secretKeyMultibase(''), /64-char/); + assert.throws(() => secretKeyMultibase(publicHex.toUpperCase()), /64-char lower-hex/); + }); +}); + +describe('buildOwnerKeyDocument', () => { + const args = { + controllerWebId: 'https://alice.example/profile/card.jsonld#me', + publicHex: '87a1c6f0e9b3d2456789abcdef0123456789abcdef0123456789abcdef012345', + secretHex: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + }; + + it('emits a W3C CID v1.0 Multikey JSON-LD document', () => { + const doc = buildOwnerKeyDocument(args); + assert.strictEqual(doc['@context'], 'https://www.w3.org/ns/cid/v1'); + assert.strictEqual(doc.type, 'Multikey'); + }); + + it('uses the WebID as controller in Phase 1 (not did:nostr)', () => { + // Phase 1 keeps the document self-consistent. did:nostr controllers + // require the Phase 2 resolver to be useful; until then a CID + // consumer would dereference an unresolvable URI. + const doc = buildOwnerKeyDocument(args); + assert.strictEqual(doc.controller, args.controllerWebId); + assert.doesNotMatch(doc.controller, /^did:nostr:/); + }); + + it('embeds both the public and secret Multikey values', () => { + const doc = buildOwnerKeyDocument(args); + assert.strictEqual(doc.publicKeyMultibase, publicKeyMultibase(args.publicHex)); + assert.strictEqual(doc.secretKeyMultibase, secretKeyMultibase(args.secretHex)); + }); + + it('does NOT include a `nostr` extension block (npub etc.)', () => { + // Resolution #2 of #437: bech32 npub is derivable from the public + // multibase value in any Nostr-aware tool. Including it would + // double the secret-on-disk surface for log/error/dump leaks + // (the symmetric `nsec` field was originally proposed alongside). + const doc = buildOwnerKeyDocument(args); + assert.strictEqual(doc.nostr, undefined); + }); + + it('rejects a missing or empty controller WebID', () => { + assert.throws(() => buildOwnerKeyDocument({ ...args, controllerWebId: '' }), /controllerWebId required/); + assert.throws(() => buildOwnerKeyDocument({ ...args, controllerWebId: undefined }), /controllerWebId required/); + }); +}); + +describe('provisionOwnerKey', () => { + it('returns the document together with raw key material for CLI display', () => { + const out = provisionOwnerKey({ controllerWebId: 'https://alice.example/profile/card.jsonld#me' }); + assert.match(out.publicHex, /^[0-9a-f]{64}$/); + assert.match(out.secretHex, /^[0-9a-f]{64}$/); + assert.strictEqual(out.publicMultibase, out.document.publicKeyMultibase); + assert.strictEqual(out.document.type, 'Multikey'); + assert.strictEqual(out.document.controller, 'https://alice.example/profile/card.jsonld#me'); + }); +}); + +describe('assertProvisionKeysCompatible', () => { + // Refuse the provisionKeys + --public combination — public mode + // bypasses WAC, which would expose the freshly-written secret on + // /private/privkey.jsonld to anyone over HTTP. + it('throws when both provisionKeys and isPublic are true', () => { + assert.throws( + () => assertProvisionKeysCompatible({ provisionKeys: true, isPublic: true }), + /cannot be combined with --public/ + ); + }); + + it('does NOT throw for provisionKeys alone (the supported case)', () => { + assert.doesNotThrow(() => + assertProvisionKeysCompatible({ provisionKeys: true, isPublic: false }) + ); + }); + + it('does NOT throw for --public alone (no secrets being written)', () => { + assert.doesNotThrow(() => + assertProvisionKeysCompatible({ provisionKeys: false, isPublic: true }) + ); + }); + + it('does NOT throw when neither flag is set', () => { + assert.doesNotThrow(() => + assertProvisionKeysCompatible({ provisionKeys: false, isPublic: false }) + ); + }); +}); + +function hexToBytes(hex) { + if (hex.length % 2 !== 0) throw new Error('hex length must be even'); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +}