@qntx/wallet-worker 0.1.0 → 0.3.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.
package/dist/node.d.mts CHANGED
@@ -2,7 +2,9 @@ import { AutoLockPolicy, PasskeyConfig, Wallet } from "@qntx/wallet";
2
2
  import { PasskeyCeremony } from "@qntx/wallet/host";
3
3
  import { Worker } from "node:worker_threads";
4
4
  //#region src/node/software-passkey.d.ts
5
- export declare function createSoftwarePasskey(seed: Uint8Array): PasskeyCeremony;
5
+ export declare function createSoftwarePasskey(seed: Uint8Array, options?: {
6
+ readonly synced?: boolean;
7
+ }): PasskeyCeremony;
6
8
  //#endregion
7
9
  //#region src/node/index.d.ts
8
10
  export type NodeWalletOptions = {
package/dist/node.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { WalletError } from "@qntx/wallet";
2
- import { connectKernel } from "@qntx/wallet/host";
2
+ import { PASSKEY_PURPOSE_VAULT, PASSKEY_PURPOSE_WALLET, connectKernel } from "@qntx/wallet/host";
3
3
  import { MessageChannel, Worker } from "node:worker_threads";
4
4
  //#region ../wallet-kernel/src/errors.ts
5
5
  /**
@@ -806,10 +806,19 @@ function cloneImage(image) {
806
806
  factors: image.factors.map(cloneFactor)
807
807
  };
808
808
  }
809
+ function conflict(message) {
810
+ return new KernelError("conflict", `vault store: ${message}`);
811
+ }
812
+ function requireMeta(meta) {
813
+ if (meta === void 0) throw conflict("vault is not initialized");
814
+ return meta;
815
+ }
809
816
  /**
810
817
  * In-memory RecordStore for the Node host and kernel tests. Loads and commits deep-copy every
811
818
  * Uint8Array, so callers can never mutate stored state. `commit` applies ops to a clone and swaps
812
- * atomically: a failing `expect-uninitialized` leaves the store untouched.
819
+ * atomically: a failing precondition (`init`, `expect-revision`, a counter op on an empty store)
820
+ * leaves the store untouched. Content ops bump `meta.revision` once per commit; an `init` commit
821
+ * writes its meta verbatim (revision stays as the initializer set it).
813
822
  */
814
823
  function createMemoryStore() {
815
824
  let image = {
@@ -822,30 +831,65 @@ function createMemoryStore() {
822
831
  let { meta } = image;
823
832
  let { records } = image;
824
833
  let { factors } = image;
834
+ let bump = false;
835
+ let initialized = false;
825
836
  for (const op of ops) switch (op.op) {
826
- case "expect-uninitialized":
827
- if (meta !== void 0 || records.length > 0 || factors.length > 0) throw new KernelError("integrity", "vault already initialized");
828
- break;
829
- case "put-meta":
837
+ case "init":
838
+ if (meta !== void 0 || records.length > 0 || factors.length > 0) throw conflict("vault already initialized");
830
839
  meta = cloneMeta(op.meta);
840
+ initialized = true;
841
+ break;
842
+ case "expect-revision":
843
+ if (meta === void 0 || meta.revision !== op.revision) throw conflict("vault changed underneath");
844
+ break;
845
+ case "set-key-id":
846
+ meta = {
847
+ ...requireMeta(meta),
848
+ keyId: op.keyId
849
+ };
850
+ bump = true;
851
+ break;
852
+ case "record-failure": {
853
+ const current = requireMeta(meta);
854
+ meta = {
855
+ ...current,
856
+ failedAttempts: current.failedAttempts + 1,
857
+ lastFailureAt: op.at
858
+ };
859
+ break;
860
+ }
861
+ case "reset-failures":
862
+ meta = {
863
+ ...requireMeta(meta),
864
+ failedAttempts: 0,
865
+ lastFailureAt: 0
866
+ };
831
867
  break;
832
868
  case "put-record":
833
869
  records = [...records.filter((record) => !(record.kind === op.record.kind && record.id === op.record.id)), cloneRecord(op.record)];
870
+ bump = true;
834
871
  break;
835
872
  case "delete-record":
836
873
  records = records.filter((record) => !(record.kind === op.kind && record.id === op.id));
874
+ bump = true;
837
875
  break;
838
876
  case "put-factor":
839
877
  factors = [...factors.filter((factor) => factor.id !== op.factor.id), cloneFactor(op.factor)];
878
+ bump = true;
840
879
  break;
841
880
  case "delete-factor":
842
881
  factors = factors.filter((factor) => factor.id !== op.id);
882
+ bump = true;
843
883
  break;
844
884
  case "clear":
845
885
  meta = void 0;
846
886
  records = [];
847
887
  factors = [];
848
888
  }
889
+ if (bump && !initialized && meta !== void 0) meta = {
890
+ ...meta,
891
+ revision: meta.revision + 1
892
+ };
849
893
  return {
850
894
  meta,
851
895
  records,
@@ -890,13 +934,25 @@ function serveStore(store, port) {
890
934
  const post = (reply) => {
891
935
  port.postMessage(reply);
892
936
  };
937
+ const failed = (id, error) => {
938
+ const code = error instanceof KernelError ? error.code : "unavailable";
939
+ post({
940
+ t: "failed",
941
+ id,
942
+ code
943
+ });
944
+ };
893
945
  const answer = async (request) => {
894
946
  if (request.t === "load") {
895
- post({
896
- t: "loaded",
897
- id: request.id,
898
- image: encodeImage(await store.load())
899
- });
947
+ try {
948
+ post({
949
+ t: "loaded",
950
+ id: request.id,
951
+ image: encodeImage(await store.load())
952
+ });
953
+ } catch (error) {
954
+ failed(request.id, error);
955
+ }
900
956
  return;
901
957
  }
902
958
  ownCommits++;
@@ -904,7 +960,8 @@ function serveStore(store, port) {
904
960
  await store.commit(request.ops);
905
961
  } catch (error) {
906
962
  ownCommits--;
907
- throw error;
963
+ failed(request.id, error);
964
+ return;
908
965
  }
909
966
  post({
910
967
  t: "committed",
@@ -943,8 +1000,11 @@ function serveStore(store, port) {
943
1000
  *
944
1001
  * CredentialId = SHA-256(seed ‖ "credential" ‖ u32be(n)) (n increments per create) PRF(salt) =
945
1002
  * HMAC-SHA256(HMAC-SHA256(seed, credentialId), SHA-256(UTF-8("WebAuthn PRF") ‖ 0x00 ‖ salt))
1003
+ * UserHandle = purpose byte ‖ SHA-256(seed ‖ "user-handle" ‖ credentialId)[0:15] — the same
1004
+ * 16-byte, purpose-prefixed shape the browser create path puts in `user.id`.
946
1005
  */
947
- function createSoftwarePasskey(seed) {
1006
+ function createSoftwarePasskey(seed, options) {
1007
+ const synced = options?.synced ?? true;
948
1008
  let counter = 0;
949
1009
  let lastCreated;
950
1010
  const known = /* @__PURE__ */ new Map();
@@ -953,25 +1013,35 @@ function createSoftwarePasskey(seed) {
953
1013
  const message = sha256(concatBytes(utf8ToBytes("WebAuthn PRF"), new Uint8Array([0]), salt));
954
1014
  return hmac(sha256, key, message);
955
1015
  };
956
- const result = (credentialId, salts) => ({
1016
+ const result = (credentialId, salts, userHandle) => ({
957
1017
  credentialId: new Uint8Array(credentialId),
958
1018
  first: prf(credentialId, salts.first),
959
- ...salts.second === void 0 ? {} : { second: prf(credentialId, salts.second) }
1019
+ ...salts.second === void 0 ? {} : { second: prf(credentialId, salts.second) },
1020
+ ...userHandle === void 0 ? {} : { userHandle: new Uint8Array(userHandle) },
1021
+ synced
960
1022
  });
961
1023
  return {
962
- create: async ({ salts }) => {
1024
+ create: async ({ purpose, salts }) => {
963
1025
  const counterBytes = /* @__PURE__ */ new Uint8Array(4);
964
1026
  new DataView(counterBytes.buffer).setUint32(0, counter);
965
1027
  counter++;
966
1028
  const credentialId = sha256(concatBytes(seed, utf8ToBytes("credential"), counterBytes));
967
- known.set(bytesToHex(credentialId), credentialId);
1029
+ const purposeByte = purpose === "wallet" ? PASSKEY_PURPOSE_WALLET : PASSKEY_PURPOSE_VAULT;
1030
+ const userHandle = /* @__PURE__ */ new Uint8Array(16);
1031
+ userHandle[0] = purposeByte;
1032
+ userHandle.set(sha256(concatBytes(seed, utf8ToBytes("user-handle"), credentialId)).subarray(0, 15), 1);
1033
+ known.set(bytesToHex(credentialId), {
1034
+ credentialId,
1035
+ userHandle
1036
+ });
968
1037
  lastCreated = credentialId;
969
1038
  return result(credentialId, salts);
970
1039
  },
971
1040
  assert: async ({ credentialIds, salts }) => {
972
1041
  const credentialId = credentialIds === void 0 ? lastCreated : credentialIds.find((id) => known.has(bytesToHex(id)));
973
1042
  if (credentialId === void 0) throw new WalletError("factor-unavailable", credentialIds === void 0 ? "no software credential exists yet" : "unknown software credential");
974
- return result(credentialId, salts);
1043
+ const entry = known.get(bytesToHex(credentialId));
1044
+ return result(credentialId, salts, entry?.userHandle);
975
1045
  }
976
1046
  };
977
1047
  }
@@ -983,14 +1053,10 @@ function createSoftwarePasskey(seed) {
983
1053
  * in-memory store, served to each worker over a MessageChannel. That is what lets a lock destroy
984
1054
  * the whole worker without losing the vault.
985
1055
  */
986
- const MAX_SESSION_MS = 432e5;
987
1056
  async function createNodeWallet(options) {
988
1057
  const { autoLock } = options;
989
1058
  if (autoLock.platform !== "web") throw new WalletError("invalid-input", "createNodeWallet accepts only the web autoLock policy");
990
- const workerData = {
991
- idleMs: autoLock.idleMinutes * 6e4,
992
- maxSessionMs: MAX_SESSION_MS
993
- };
1059
+ const workerData = { idleMinutes: autoLock.idleMinutes };
994
1060
  const store = createMemoryStore();
995
1061
  const kernelUrl = options.kernelUrl ?? new URL("kernel.node.js", import.meta.url);
996
1062
  const spawn = () => {
@@ -1035,7 +1101,13 @@ async function createNodeWallet(options) {
1035
1101
  return connectKernel({
1036
1102
  spawn,
1037
1103
  passkey: options.passkey,
1038
- ceremony: options.authenticator
1104
+ ceremony: options.authenticator,
1105
+ setTimer: (delayMs, fn) => {
1106
+ const timer = setTimeout(fn, delayMs);
1107
+ return () => {
1108
+ clearTimeout(timer);
1109
+ };
1110
+ }
1039
1111
  });
1040
1112
  }
1041
1113
  //#endregion