@le-space/p2pass 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,12 +24,13 @@ export class IdentityService {
24
24
  * If no credentials, creates new passkey.
25
25
  *
26
26
  * @param {'platform'|'cross-platform'} [authenticatorType]
27
- * @param {{ preferWorkerMode?: boolean, signingPreference?: import('./signing-preference.js').SigningPreference }} [options]
27
+ * @param {{ preferWorkerMode?: boolean, signingPreference?: import('./signing-preference.js').SigningPreference, webauthnUserLabel?: string }} [options]
28
28
  * @returns {Promise<{ mode: string, did: string, algorithm: string }>}
29
29
  */
30
30
  initialize(authenticatorType?: "platform" | "cross-platform", options?: {
31
31
  preferWorkerMode?: boolean;
32
32
  signingPreference?: import("./signing-preference.js").SigningPreference;
33
+ webauthnUserLabel?: string;
33
34
  }): Promise<{
34
35
  mode: string;
35
36
  did: string;
@@ -38,12 +39,13 @@ export class IdentityService {
38
39
  /**
39
40
  * Force create a new identity (discards existing).
40
41
  * @param {'platform'|'cross-platform'} [authenticatorType]
41
- * @param {{ preferWorkerMode?: boolean, signingPreference?: import('./signing-preference.js').SigningPreference }} [options]
42
+ * @param {{ preferWorkerMode?: boolean, signingPreference?: import('./signing-preference.js').SigningPreference, webauthnUserLabel?: string }} [options]
42
43
  * @returns {Promise<{ mode: string, did: string, algorithm: string }>}
43
44
  */
44
45
  createNewIdentity(authenticatorType?: "platform" | "cross-platform", options?: {
45
46
  preferWorkerMode?: boolean;
46
47
  signingPreference?: import("./signing-preference.js").SigningPreference;
48
+ webauthnUserLabel?: string;
47
49
  }): Promise<{
48
50
  mode: string;
49
51
  did: string;
@@ -20,7 +20,6 @@ import {
20
20
 
21
21
  import {
22
22
  storeKeypairEntry,
23
- getKeypairEntry,
24
23
  storeArchiveEntry,
25
24
  getArchiveEntry,
26
25
  listKeypairs,
@@ -36,6 +35,38 @@ import { resolveSigningPreference } from './signing-preference.js';
36
35
 
37
36
  const ARCHIVE_CACHE_KEY = 'p2p_passkeys_worker_archive';
38
37
 
38
+ /** WebAuthn user.id must be at most 64 bytes (UTF-8). */
39
+ const WEBAUTHN_USER_ID_MAX_BYTES = 64;
40
+
41
+ /**
42
+ * Build {@link https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialuserentity PublicKeyCredentialUserEntity}.
43
+ * Empty label keeps prior defaults (random opaque user.id).
44
+ *
45
+ * @param {string} label
46
+ * @returns {Promise<{ id: Uint8Array, name: string, displayName: string }>}
47
+ */
48
+ async function publicKeyCredentialUserFromLabel(label) {
49
+ const trimmed = (typeof label === 'string' ? label : '').trim();
50
+ if (!trimmed) {
51
+ return {
52
+ id: crypto.getRandomValues(new Uint8Array(16)),
53
+ name: 'p2p-user',
54
+ displayName: 'P2P User',
55
+ };
56
+ }
57
+ const encoder = new TextEncoder();
58
+ let idBytes = encoder.encode(trimmed);
59
+ if (idBytes.length > WEBAUTHN_USER_ID_MAX_BYTES) {
60
+ const digest = await crypto.subtle.digest('SHA-256', idBytes);
61
+ idBytes = new Uint8Array(digest);
62
+ }
63
+ return {
64
+ id: idBytes,
65
+ name: trimmed,
66
+ displayName: trimmed,
67
+ };
68
+ }
69
+
39
70
  /**
40
71
  * Best-effort: this origin likely already has passkey / identity material (no WebAuthn prompt).
41
72
  * Uses the same signals as restore paths — false negatives are OK (same handlers still apply).
@@ -118,11 +149,11 @@ export class IdentityService {
118
149
  * If no credentials, creates new passkey.
119
150
  *
120
151
  * @param {'platform'|'cross-platform'} [authenticatorType]
121
- * @param {{ preferWorkerMode?: boolean, signingPreference?: import('./signing-preference.js').SigningPreference }} [options]
152
+ * @param {{ preferWorkerMode?: boolean, signingPreference?: import('./signing-preference.js').SigningPreference, webauthnUserLabel?: string }} [options]
122
153
  * @returns {Promise<{ mode: string, did: string, algorithm: string }>}
123
154
  */
124
155
  async initialize(authenticatorType, options = {}) {
125
- const { preferWorkerMode = false, signingPreference = null } = options;
156
+ const { preferWorkerMode = false, signingPreference = null, webauthnUserLabel = '' } = options;
126
157
  const pref = resolveSigningPreference({ preferWorkerMode, signingPreference });
127
158
  const preferWorker = pref === 'worker';
128
159
  const forceP256Hardware = pref === 'hardware-p256';
@@ -135,10 +166,16 @@ export class IdentityService {
135
166
  // Try hardware mode first (unless worker mode is selected)
136
167
  if (!preferWorker) {
137
168
  try {
138
- const signer = await this.#hardwareService.initialize({
169
+ const trimmedLabel = webauthnUserLabel.trim();
170
+ const hwOpts = {
139
171
  authenticatorType,
140
172
  forceP256: forceP256Hardware,
141
- });
173
+ };
174
+ if (trimmedLabel) {
175
+ hwOpts.userId = trimmedLabel;
176
+ hwOpts.displayName = trimmedLabel;
177
+ }
178
+ const signer = await this.#hardwareService.initialize(hwOpts);
142
179
 
143
180
  if (signer) {
144
181
  this.#mode = 'hardware';
@@ -161,7 +198,7 @@ export class IdentityService {
161
198
  }
162
199
 
163
200
  // No existing identity — create new worker identity
164
- await this.#createWorkerIdentity(authenticatorType);
201
+ await this.#createWorkerIdentity(authenticatorType, webauthnUserLabel);
165
202
  console.log(`[identity] Created new worker identity, DID: ${this.#did}`);
166
203
  return this.getSigningMode();
167
204
  }
@@ -169,7 +206,7 @@ export class IdentityService {
169
206
  /**
170
207
  * Force create a new identity (discards existing).
171
208
  * @param {'platform'|'cross-platform'} [authenticatorType]
172
- * @param {{ preferWorkerMode?: boolean, signingPreference?: import('./signing-preference.js').SigningPreference }} [options]
209
+ * @param {{ preferWorkerMode?: boolean, signingPreference?: import('./signing-preference.js').SigningPreference, webauthnUserLabel?: string }} [options]
173
210
  * @returns {Promise<{ mode: string, did: string, algorithm: string }>}
174
211
  */
175
212
  async createNewIdentity(authenticatorType, options = {}) {
@@ -190,7 +227,7 @@ export class IdentityService {
190
227
  */
191
228
  async initializeFromRecovery() {
192
229
  console.log('[identity] Starting recovery via discoverable credential...');
193
- const { prfSeed, rawCredentialId, credential } = await recoverPrfSeed();
230
+ const { prfSeed, rawCredentialId } = await recoverPrfSeed();
194
231
 
195
232
  this.#prfSeed = prfSeed;
196
233
  this.#ipnsKeyPair = await deriveIPNSKeyPair(prfSeed);
@@ -415,9 +452,9 @@ export class IdentityService {
415
452
  /**
416
453
  * Create a new worker-mode Ed25519 identity.
417
454
  */
418
- async #createWorkerIdentity(authenticatorType) {
455
+ async #createWorkerIdentity(authenticatorType, webauthnUserLabel) {
419
456
  // Create WebAuthn credential with PRF
420
- const credential = await this.#createWebAuthnCredential(authenticatorType);
457
+ const credential = await this.#createWebAuthnCredential(authenticatorType, webauthnUserLabel);
421
458
 
422
459
  // Extract PRF seed
423
460
  const { seed: prfSeed } = await extractPrfSeedFromCredential(credential);
@@ -468,9 +505,10 @@ export class IdentityService {
468
505
  /**
469
506
  * Create a WebAuthn credential with PRF extension.
470
507
  */
471
- async #createWebAuthnCredential(authenticatorType) {
508
+ async #createWebAuthnCredential(authenticatorType, webauthnUserLabel) {
472
509
  const challenge = crypto.getRandomValues(new Uint8Array(32));
473
510
  const prfSalt = await computeDeterministicPrfSalt();
511
+ const userEntity = await publicKeyCredentialUserFromLabel(webauthnUserLabel);
474
512
 
475
513
  const createOptions = {
476
514
  publicKey: {
@@ -479,9 +517,9 @@ export class IdentityService {
479
517
  id: globalThis.location?.hostname || 'localhost',
480
518
  },
481
519
  user: {
482
- id: crypto.getRandomValues(new Uint8Array(16)),
483
- name: 'p2p-user',
484
- displayName: 'P2P User',
520
+ id: userEntity.id,
521
+ name: userEntity.name,
522
+ displayName: userEntity.displayName,
485
523
  },
486
524
  challenge,
487
525
  pubKeyCredParams: [
@@ -5,7 +5,7 @@
5
5
  writeSigningPreferenceToStorage,
6
6
  } from '../identity/signing-preference.js';
7
7
  import { Upload, LogOut, Loader2, AlertCircle, CheckCircle, Download } from 'lucide-svelte';
8
- import { listSpaces, getSpaceUsage } from './storacha-backup.js';
8
+ import { getSpaceUsage } from './storacha-backup.js';
9
9
  import { OrbitDBStorachaBridge } from 'orbitdb-storacha-bridge';
10
10
  import { IdentityService, hasLocalPasskeyHint } from '../identity/identity-service.js';
11
11
  import { getStoredSigningMode } from '../identity/mode-detector.js';
@@ -28,7 +28,6 @@
28
28
  listDelegations,
29
29
  storeDelegationEntry,
30
30
  } from '../registry/device-registry.js';
31
- import { deriveIPNSKeyPair } from '../recovery/ipns-key.js';
32
31
  import {
33
32
  createManifest,
34
33
  publishManifest,
@@ -36,7 +35,7 @@
36
35
  uploadArchiveToIPFS,
37
36
  fetchArchiveFromIPFS,
38
37
  } from '../recovery/manifest.js';
39
- import { backupRegistryDb, restoreRegistryDb } from '../backup/registry-backup.js';
38
+ import { backupRegistryDb } from '../backup/registry-backup.js';
40
39
  import { MultiDeviceManager } from '../registry/manager.js';
41
40
  import { detectDeviceLabel, pairingFlow } from '../registry/pairing-protocol.js';
42
41
  import { loadWebAuthnCredentialSafe } from '@le-space/orbitdb-identity-provider-webauthn-did/standalone';
@@ -90,7 +89,6 @@
90
89
  // Component state
91
90
  let showStoracha = $state(true);
92
91
  let isLoading = $state(false);
93
- let status = $state('');
94
92
  let error = $state(null);
95
93
  let success = $state(null);
96
94
 
@@ -116,7 +114,8 @@
116
114
  let signingMode = $state(null); // { mode, did, algorithm, secure }
117
115
  let delegationText = $state(''); // textarea for pasting delegation
118
116
  let isAuthenticating = $state(false);
119
- let spaces = $state([]);
117
+ /** Shown when creating a new passkey; sent as WebAuthn user.id / name / displayName (worker path). */
118
+ let passkeyUserLabel = $state('');
120
119
  let spaceUsage = $state(null);
121
120
 
122
121
  // Registry DB state
@@ -299,16 +298,13 @@
299
298
  }, 5000);
300
299
  }
301
300
 
302
- function clearForms() {
303
- delegationText = '';
304
- }
305
-
306
301
  async function handleAuthenticate() {
307
302
  isAuthenticating = true;
308
303
  try {
309
304
  signingMode = await identityService.initialize(undefined, {
310
305
  preferWorkerMode,
311
306
  signingPreference: signingPreferenceOverride ?? selectedSigningPreference,
307
+ ...(localPasskeyDetected ? {} : { webauthnUserLabel: passkeyUserLabel }),
312
308
  });
313
309
  showMessage(`Authenticated! Mode: ${signingMode.algorithm} (${signingMode.mode})`);
314
310
 
@@ -890,7 +886,6 @@
890
886
  isLoggedIn = false;
891
887
  client = null;
892
888
  currentSpace = null;
893
- spaces = [];
894
889
  spaceUsage = null;
895
890
  signingMode = null;
896
891
  await clearStoredDelegation(registryDb);
@@ -912,21 +907,6 @@
912
907
  }
913
908
  }
914
909
 
915
- async function loadSpaces() {
916
- if (!client) return;
917
- isLoading = true;
918
- status = 'Loading spaces...';
919
- try {
920
- spaces = await listSpaces(client);
921
- await loadSpaceUsage();
922
- } catch (err) {
923
- showMessage(`Failed to load spaces: ${err.message}`, 'error');
924
- } finally {
925
- isLoading = false;
926
- status = '';
927
- }
928
- }
929
-
930
910
  async function handleBackup() {
931
911
  if (!bridge) {
932
912
  showMessage('Please log in first', 'error');
@@ -943,7 +923,6 @@
943
923
 
944
924
  isLoading = true;
945
925
  resetProgress();
946
- status = 'Preparing backup...';
947
926
 
948
927
  try {
949
928
  // Backup user database if available
@@ -979,7 +958,6 @@
979
958
  showMessage(`Backup failed: ${err.message}`, 'error');
980
959
  } finally {
981
960
  isLoading = false;
982
- status = '';
983
961
  resetProgress();
984
962
  }
985
963
  }
@@ -992,12 +970,10 @@
992
970
 
993
971
  isLoading = true;
994
972
  resetProgress();
995
- status = 'Preparing restore...';
996
973
 
997
974
  try {
998
975
  // Close existing database if provided
999
976
  if (database) {
1000
- status = 'Closing existing database...';
1001
977
  try {
1002
978
  await database.close();
1003
979
  } catch {
@@ -1005,8 +981,6 @@
1005
981
  }
1006
982
  }
1007
983
 
1008
- status = 'Starting restore...';
1009
-
1010
984
  if (!bridge) {
1011
985
  throw new Error('Bridge not initialized. Please connect to Storacha first.');
1012
986
  }
@@ -1028,7 +1002,6 @@
1028
1002
  showMessage(`Restore failed: ${err.message}`, 'error');
1029
1003
  } finally {
1030
1004
  isLoading = false;
1031
- status = '';
1032
1005
  resetProgress();
1033
1006
  }
1034
1007
  }
@@ -1158,7 +1131,7 @@
1158
1131
  </div>
1159
1132
  {:else}
1160
1133
  <div style="display: flex; flex-direction: column; gap: 0.375rem;">
1161
- {#each devices as device}
1134
+ {#each devices as device, i (device.credential_id || device.ed25519_did || device.device_label || i)}
1162
1135
  <div
1163
1136
  data-testid="storacha-linked-device-row"
1164
1137
  data-device-label={device.device_label || ''}
@@ -1516,6 +1489,33 @@
1516
1489
  </div>
1517
1490
  </fieldset>
1518
1491
 
1492
+ {#if !localPasskeyDetected}
1493
+ <label
1494
+ style="display: flex; width: 100%; max-width: 22rem; flex-direction: column; align-items: stretch; gap: 0.35rem; text-align: left; box-sizing: border-box;"
1495
+ >
1496
+ <span
1497
+ style="font-size: 0.7rem; font-weight: 600; color: #374151; font-family: 'Epilogue', sans-serif;"
1498
+ >
1499
+ Passkey name (WebAuthn user ID)
1500
+ </span>
1501
+ <input
1502
+ type="text"
1503
+ data-testid="storacha-passkey-user-label"
1504
+ bind:value={passkeyUserLabel}
1505
+ disabled={isAuthenticating || signingPreferenceOverride != null}
1506
+ placeholder="e.g. Work laptop"
1507
+ autocomplete="username"
1508
+ style="width: 100%; box-sizing: border-box; border-radius: 0.375rem; border: 1px solid rgba(233, 19, 21, 0.25); padding: 0.5rem 0.625rem; font-size: 0.8rem; font-family: 'DM Sans', sans-serif; color: #111827; background: rgba(255, 255, 255, 0.9);"
1509
+ />
1510
+ <span
1511
+ style="font-size: 0.65rem; color: #6b7280; font-family: 'DM Sans', sans-serif; line-height: 1.35;"
1512
+ >
1513
+ Optional. Used for user.id (and display name) when creating a new passkey. Leave blank for an
1514
+ anonymous default.
1515
+ </span>
1516
+ </label>
1517
+ {/if}
1518
+
1519
1519
  <button
1520
1520
  data-testid="storacha-passkey-primary"
1521
1521
  class="storacha-btn-primary"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/p2pass",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "P2Pass — peer-to-peer passkeys, UCANs, OrbitDB registry sync, and Storacha backup (Svelte)",
6
6
  "license": "MIT",