wallet-kit / test / vectors / independent.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
/**
 * The independent derivation path — no imports from dist/, no side effects.
 *
 * Everything the golden vectors are built and checked with lives here: a
 * re-implementation of canonical JSON from its spec sentence, the four
 * signature preimages written out as literal templates, Ed25519 via
 * @noble/curves and SHA-256 via node:crypto. Both build-vectors.mjs (which
 * writes the fixtures) and vectors.test.mjs (which re-checks them against the
 * kit) import this module, so a fixture and its test never share a line of the
 * kit's own code.
 *
 * Pure module: importing it computes nothing and writes nothing.
 */

import { createHash } from "node:crypto";
import { ed25519 } from "@noble/curves/ed25519";

// ---- fixed test material ----------------------------------------------------
// Test-only seeds. Nothing here is a real identity.

export const seedBytes = (fill) => {
  const b = new Uint8Array(32);
  if (typeof fill === "number") b.fill(fill);
  else for (let i = 0; i < 32; i++) b[i] = fill(i);
  return b;
};

export const SEED_PAYER = seedBytes((i) => i); // bytes 00..1f
export const SEED_PAYEE = seedBytes((i) => 32 + i); // bytes 20..3f
export const SEED_BANK = seedBytes(0x11);
export const SEED_BANK2 = seedBytes(0x33);

/** 2025-01-01T00:00:00Z. Fixed and in the past, so every verdict is stable. */
export const TS_FIXED = 1735689600000;
/** The order id of the fixture order: 16 bytes, 0xA0..0xAF -> 22 b64url chars. */
export const ORDER_ID_BYTES = new Uint8Array(16).map((_, i) => 0xa0 + i);

/** The fixture hashlock preimage: 32 bytes, 0xC0..0xDF. Fixed, never random. */
export const HASHLOCK_PRE_BYTES = new Uint8Array(32).map((_, i) => 0xc0 + i);

export const b64url = (bytes) => Buffer.from(bytes).toString("base64url");
export const utf8 = (s) => new Uint8Array(Buffer.from(s, "utf8"));
export const sha256b64url = (s) => createHash("sha256").update(Buffer.from(s, "utf8")).digest("base64url");

/** The seed IS the raw Ed25519 private key — id-kit and @noble/curves agree. */
export const pubOf = (seed) => b64url(ed25519.getPublicKey(seed));
export const signOver = (seed, preimage) => b64url(ed25519.sign(utf8(preimage), seed));
export const verifyOver = (pub, sigB64url, preimage) =>
  ed25519.verify(Buffer.from(sigB64url, "base64url"), utf8(preimage), Buffer.from(pub, "base64url"));

// ---- canonical JSON, re-implemented from the spec ---------------------------
// "sorted keys, no whitespace, JSON-safe values only". Enough of the dispatch
// for these artifacts (objects, strings, integers) and no more — its job is to
// disagree with a wrong implementation, not to be one.

export function canonIndependent(v) {
  if (v === null) return "null";
  const t = typeof v;
  if (t === "number" || t === "boolean" || t === "string") return JSON.stringify(v);
  if (Array.isArray(v)) return "[" + v.map(canonIndependent).join(",") + "]";
  if (t === "object") {
    const keys = Object.keys(v)
      .filter((k) => v[k] !== undefined)
      .sort();
    return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonIndependent(v[k])).join(",") + "}";
  }
  throw new Error("canonIndependent: unsupported " + t);
}

/**
 * The preimages, written out by hand rather than assembled.
 *
 * These templates are the point of this file: a reader can hold one next to the
 * artifact and check, character by character, that the keys are in ASCII order,
 * that there is no whitespace, and that the domain prefix is the right one.
 * `canonIndependent` then has to agree with the template, and the kit has to
 * agree with both.
 */
export function preimageByHand(kind, a) {
  const s = JSON.stringify; // one quoting rule, JSON's
  if (kind === "wpr")
    return (
      `wpay-req|v1|{"amt":${a.amt},"ctx":${s(a.ctx)},"cur":${s(a.cur)},"exp":${a.exp},` +
      `"memo":${s(a.memo)},"t":"wpr","to":${s(a.to)},"tox":${s(a.tox)},"v":1}`
    );
  if (kind === "wpo")
    return (
      `wpay-ord|v1|{"amt":${a.amt},"ctx":${s(a.ctx)},"cur":${s(a.cur)},"exp":${a.exp},` +
      `"from":${s(a.from)},"id":${s(a.id)},"memo":${s(a.memo)},"seq":${a.seq},"t":"wpo",` +
      `"to":${s(a.to)},"ts":${a.ts},"v":1}`
    );
  if (kind === "wrc")
    return (
      `wpay-rcp|v1|{"bank":${s(a.bank)},"po":${canonIndependent(a.po)},"seq":${s(a.seq)},` +
      `"t":"wrc","ts":${a.ts},"v":1}`
    );
  if (kind === "wrj")
    return (
      `wpay-rcp|v1|{"bank":${s(a.bank)},"po":${canonIndependent(a.po)},"t":"wrj","ts":${a.ts},` +
      `"v":1,"why":${s(a.why)}}`
    );
  if (kind === "wri")
    return (
      `wpay-iss|v1|{"amt":${a.amt},"bank":${s(a.bank)},"cur":${s(a.cur)},"h":${s(a.h)},` +
      `"seq":${a.seq},"t":"wri","to":${s(a.to)},"ts":${a.ts},"v":1}`
    );
  // wlk is wpo's template with "hash" inserted — and note where ASCII sorting
  // puts it: after "from", before "id". The WIRE order is different again
  // (`hash` sits between `to` and `ctx`), which is exactly the wpo/canon split
  // this file exists to keep visible.
  if (kind === "wlk")
    return (
      `wpay-lock|v1|{"amt":${a.amt},"ctx":${s(a.ctx)},"cur":${s(a.cur)},"exp":${a.exp},` +
      `"from":${s(a.from)},"hash":${s(a.hash)},"id":${s(a.id)},"memo":${s(a.memo)},` +
      `"seq":${a.seq},"t":"wlk","to":${s(a.to)},"ts":${a.ts},"v":1}`
    );
  // wlr shares wrc's DOMAIN. The two preimages differ only because `t` is
  // inside canon() and the embedded artifact is under a different key — which
  // is the whole argument for letting them share, so it is worth seeing here.
  if (kind === "wlr")
    return (
      `wpay-rcp|v1|{"bank":${s(a.bank)},"lk":${canonIndependent(a.lk)},"seq":${s(a.seq)},` +
      `"t":"wlr","ts":${a.ts},"v":1}`
    );
  if (kind === "wrl")
    return (
      `wpay-rel|v1|{"cur":${s(a.cur)},"lh":${s(a.lh)},"t":"wrl","to":${s(a.to)},` +
      `"ts":${a.ts},"v":1}`
    );
  throw new Error("no such kind " + kind);
}

const DOMAIN = {
  wpr: "wpay-req",
  wpo: "wpay-ord",
  wrc: "wpay-rcp",
  wrj: "wpay-rcp",
  wri: "wpay-iss",
  wlk: "wpay-lock",
  wlr: "wpay-rcp",
  wrl: "wpay-rel",
};

/** The template and the re-implemented canon must agree before anything ships. */
export function preimage(kind, unsigned) {
  const viaCanon = `${DOMAIN[kind]}|v1|${canonIndependent(unsigned)}`;
  const viaHand = preimageByHand(kind, unsigned);
  if (viaCanon !== viaHand)
    throw new Error(`preimage disagreement for ${kind}:\n  canon: ${viaCanon}\n  hand : ${viaHand}`);
  return viaCanon;
}

/** Compose + sign one artifact the independent way. `sigKey` is "sig" or "bsig". */
export function signArtifact(kind, unsigned, seed) {
  // the BANK signs with `bsig`; a payer, a payee or a beneficiary signs with `sig`
  const field =
    kind === "wrc" || kind === "wrj" || kind === "wri" || kind === "wlr" ? "bsig" : "sig";
  return { ...unsigned, [field]: signOver(seed, preimage(kind, unsigned)) };
}

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/wallet-kit.git