nervur 0.12.0 → 0.14.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/tools.mjs CHANGED
@@ -1,13 +1,57 @@
1
- import {
2
- createCipheriv,
3
- createDecipheriv,
4
- createHash,
5
- createPublicKey,
6
- randomBytes,
7
- verify,
8
- } from 'node:crypto';
1
+ import { ed25519 } from '@noble/curves/ed25519.js';
2
+ import { sha256 } from '@noble/hashes/sha2.js';
9
3
 
10
- const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
4
+ const HEX = '0123456789abcdef';
5
+
6
+ const utf8 = new TextEncoder();
7
+ const text8 = new TextDecoder();
8
+
9
+ // Buffer is Node's alone. Every byte string here is a Uint8Array, and these
10
+ // are the encodings the substrate names: hex on a digest, base64 on a
11
+ // signature, utf8 under canon.
12
+ const Bytes = Object.freeze({
13
+ base64: (bytes) => {
14
+ let binary = '';
15
+ for (const byte of bytes) {
16
+ binary += String.fromCharCode(byte);
17
+ }
18
+ return btoa(binary);
19
+ },
20
+ concat: (parts) => {
21
+ const whole = new Uint8Array(parts.reduce((run, one) => run + one.length, 0));
22
+ let at = 0;
23
+ for (const one of parts) {
24
+ whole.set(one, at);
25
+ at += one.length;
26
+ }
27
+ return whole;
28
+ },
29
+ equal: (one, other) => one.length === other.length && one.every((byte, at) => byte === other[at]),
30
+ hex: (bytes) => {
31
+ let out = '';
32
+ for (const byte of bytes) {
33
+ out += HEX[byte >> 4] + HEX[byte & 15];
34
+ }
35
+ return out;
36
+ },
37
+ of: (value) => utf8.encode(value),
38
+ read: (bytes) => text8.decode(bytes),
39
+ unbase64: (value) => {
40
+ const binary = atob(value);
41
+ const bytes = new Uint8Array(binary.length);
42
+ for (let at = 0; at < binary.length; at += 1) {
43
+ bytes[at] = binary.charCodeAt(at);
44
+ }
45
+ return bytes;
46
+ },
47
+ unhex: (value) => {
48
+ const bytes = new Uint8Array(value.length >> 1);
49
+ for (let at = 0; at < bytes.length; at += 1) {
50
+ bytes[at] = Number.parseInt(value.substr(at << 1, 2), 16);
51
+ }
52
+ return bytes;
53
+ },
54
+ });
11
55
 
12
56
  const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
13
57
 
@@ -70,21 +114,23 @@ const unbase32 = (text) => {
70
114
  bits -= 8;
71
115
  }
72
116
  }
73
- return Buffer.from(out);
117
+ return Uint8Array.from(out);
74
118
  };
75
119
 
76
120
  const Tools = Object.freeze({
77
121
  address: (pk) => {
78
- const payload = Buffer.concat([Buffer.from([VERSION_G]), pk]);
122
+ const payload = Bytes.concat([Uint8Array.of(VERSION_G), pk]);
79
123
  const crc = crc16(payload);
80
- return base32(Buffer.concat([payload, Buffer.from([crc & 0xff, crc >> 8])]));
124
+ return base32(Bytes.concat([payload, Uint8Array.of(crc & 0xff, crc >> 8)]));
81
125
  },
82
- canon: (claim) => Buffer.from(JSON.stringify(ordered(claim))),
83
- derive: (root, label) => createHash('sha256').update(root).update(label).digest(),
84
- digest: (bytes) => createHash('sha256').update(bytes).digest('hex'),
85
- envelope: (voice, to, op, seq) => {
126
+ canon: (claim) => Bytes.of(JSON.stringify(ordered(claim))),
127
+ // The label arrives as text and the digest is over its utf8 bytes, which is
128
+ // the byte string the substrate has always hashed.
129
+ derive: (root, label) => sha256.create().update(root).update(Bytes.of(label)).digest(),
130
+ digest: (bytes) => Bytes.hex(sha256(bytes)),
131
+ envelope: async (voice, to, op, seq) => {
86
132
  const from = Tools.address(voice.pk);
87
- return { from, op, seq, sig: voice.sign(Tools.canon({ from, op, seq, to })), to };
133
+ return { from, op, seq, sig: await voice.sign(Tools.canon({ from, op, seq, to })), to };
88
134
  },
89
135
  pkOf: (address) => {
90
136
  const bytes = typeof address === 'string' && address.length === 56 ? unbase32(address) : null;
@@ -93,39 +139,12 @@ const Tools = Object.freeze({
93
139
  }
94
140
  const payload = bytes.subarray(0, 33);
95
141
  const checksum = bytes[33] | (bytes[34] << 8);
96
- return crc16(payload) === checksum ? Buffer.from(payload.subarray(1)) : null;
97
- },
98
- seal: (secret, value) => {
99
- const iv = randomBytes(12);
100
- const cipher = createCipheriv('aes-256-gcm', secret, iv);
101
- const sealed = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
102
- return Buffer.concat([iv, cipher.getAuthTag(), sealed]).toString('base64');
103
- },
104
- secret: () => randomBytes(32),
105
- unseal: (secret, sealed) => {
106
- try {
107
- const bytes = Buffer.from(sealed, 'base64');
108
- const decipher = createDecipheriv('aes-256-gcm', secret, bytes.subarray(0, 12));
109
- decipher.setAuthTag(bytes.subarray(12, 28));
110
- return JSON.parse(
111
- Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString('utf8'),
112
- );
113
- } catch {
114
- return undefined;
115
- }
142
+ return crc16(payload) === checksum ? payload.slice(1) : null;
116
143
  },
144
+ secret: () => crypto.getRandomValues(new Uint8Array(32)),
117
145
  verifies: (pk, bytes, sig) => {
118
146
  try {
119
- return verify(
120
- null,
121
- bytes,
122
- createPublicKey({
123
- key: Buffer.concat([ED25519_SPKI_PREFIX, pk]),
124
- format: 'der',
125
- type: 'spki',
126
- }),
127
- Buffer.from(sig, 'base64'),
128
- );
147
+ return ed25519.verify(Bytes.unbase64(sig), bytes, pk);
129
148
  } catch {
130
149
  return false;
131
150
  }
@@ -140,4 +159,4 @@ const Stamp = () => {
140
159
  };
141
160
  };
142
161
 
143
- export { MEASURE, SPAN, Stamp, Tools };
162
+ export { Bytes, MEASURE, SPAN, Stamp, Tools };
package/voice.mjs CHANGED
@@ -1,69 +1,63 @@
1
- import {
2
- createHash,
3
- createPrivateKey,
4
- createPublicKey,
5
- diffieHellman,
6
- generateKeyPairSync,
7
- sign,
8
- } from 'node:crypto';
9
- import { Tools } from './tools.mjs';
1
+ import { ed25519, x25519 } from '@noble/curves/ed25519.js';
2
+ import { sha256 } from '@noble/hashes/sha2.js';
3
+ import { Bytes, Tools } from './tools.mjs';
10
4
 
11
- const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
5
+ // A private key travels as PKCS8 DER in base64 — the shape every stored
6
+ // bundle and every hand on the wire already carries — and a PKCS8 Ed25519 or
7
+ // X25519 key is one fixed prefix and the thirty-two seed bytes, so the bytes
8
+ // are the same whoever assembles them.
9
+ const ED_PKCS8 = Bytes.unhex('302e020100300506032b657004220420');
10
+ const X_PKCS8 = Bytes.unhex('302e020100300506032b656e04220420');
12
11
 
13
- const packed = (privateKey) =>
14
- privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64');
12
+ const seeded = () => crypto.getRandomValues(new Uint8Array(32));
15
13
 
16
- const unpacked = (text) =>
17
- createPrivateKey({ key: Buffer.from(text, 'base64'), format: 'der', type: 'pkcs8' });
14
+ const packed = (raw, prefix) => Bytes.base64(Bytes.concat([prefix, raw]));
18
15
 
19
- const rawPk = (privateKey) =>
20
- createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).subarray(-32);
16
+ const unpacked = (text, prefix) => {
17
+ const der = Bytes.unbase64(text);
18
+ if (der.length !== prefix.length + 32 || !Bytes.equal(der.subarray(0, prefix.length), prefix)) {
19
+ throw new Error('not a key of this curve');
20
+ }
21
+ return der.subarray(prefix.length);
22
+ };
21
23
 
22
- const agreed = (privateKey, rawPub) =>
23
- createHash('sha256')
24
- .update(
25
- diffieHellman({
26
- privateKey,
27
- publicKey: createPublicKey({
28
- key: Buffer.concat([X25519_SPKI_PREFIX, rawPub]),
29
- format: 'der',
30
- type: 'spki',
31
- }),
32
- }),
33
- )
34
- .digest();
24
+ const agreed = (box, rawPub) => sha256(x25519.getSharedSecret(box, rawPub));
35
25
 
36
26
  const committed = /^[0-9a-f]{64}$/;
37
27
 
38
28
  const voiced = ({ present, next, box, nextPkHash }) =>
39
29
  Object.freeze({
40
- boxPk: rawPk(box),
41
- nextPkHash: next === undefined ? nextPkHash : Tools.digest(rawPk(next)),
42
- open: (wrapped) => {
30
+ boxPk: x25519.getPublicKey(box),
31
+ nextPkHash: next === undefined ? nextPkHash : Tools.digest(ed25519.getPublicKey(next)),
32
+ open: (wrapped, cipher) => {
43
33
  try {
44
34
  const [ephemeral, sealed] = wrapped.split('.');
45
- return Tools.unseal(agreed(box, Buffer.from(ephemeral, 'base64')), sealed);
35
+ return cipher.unseal(agreed(box, Bytes.unbase64(ephemeral)), sealed);
46
36
  } catch {
47
37
  return undefined;
48
38
  }
49
39
  },
50
40
  hand: () => ({
51
- box: packed(box),
52
- nextPkHash: next === undefined ? nextPkHash : Tools.digest(rawPk(next)),
53
- present: packed(present),
41
+ box: packed(box, X_PKCS8),
42
+ nextPkHash: next === undefined ? nextPkHash : Tools.digest(ed25519.getPublicKey(next)),
43
+ present: packed(present, ED_PKCS8),
54
44
  }),
55
45
  pack: () =>
56
46
  next === undefined
57
- ? { box: packed(box), nextPkHash, present: packed(present) }
58
- : { box: packed(box), next: packed(next), present: packed(present) },
59
- pk: rawPk(present),
60
- sign: (bytes) => sign(null, bytes, present).toString('base64'),
47
+ ? { box: packed(box, X_PKCS8), nextPkHash, present: packed(present, ED_PKCS8) }
48
+ : {
49
+ box: packed(box, X_PKCS8),
50
+ next: packed(next, ED_PKCS8),
51
+ present: packed(present, ED_PKCS8),
52
+ },
53
+ pk: ed25519.getPublicKey(present),
54
+ sign: (bytes) => Bytes.base64(ed25519.sign(bytes, present)),
61
55
  succeed: () =>
62
56
  next === undefined
63
57
  ? undefined
64
58
  : voiced({
65
- box: generateKeyPairSync('x25519').privateKey,
66
- next: generateKeyPairSync('ed25519').privateKey,
59
+ box: seeded(),
60
+ next: seeded(),
67
61
  present: next,
68
62
  }),
69
63
  });
@@ -72,18 +66,10 @@ const Voice = Object.freeze(
72
66
  Object.assign(
73
67
  (nextPkHash) => {
74
68
  if (nextPkHash === undefined) {
75
- return voiced({
76
- box: generateKeyPairSync('x25519').privateKey,
77
- next: generateKeyPairSync('ed25519').privateKey,
78
- present: generateKeyPairSync('ed25519').privateKey,
79
- });
69
+ return voiced({ box: seeded(), next: seeded(), present: seeded() });
80
70
  }
81
71
  return committed.test(nextPkHash)
82
- ? voiced({
83
- box: generateKeyPairSync('x25519').privateKey,
84
- nextPkHash,
85
- present: generateKeyPairSync('ed25519').privateKey,
86
- })
72
+ ? voiced({ box: seeded(), nextPkHash, present: seeded() })
87
73
  : undefined;
88
74
  },
89
75
  {
@@ -92,27 +78,27 @@ const Voice = Object.freeze(
92
78
  if (bundle.next === undefined) {
93
79
  return committed.test(bundle.nextPkHash)
94
80
  ? voiced({
95
- box: unpacked(bundle.box),
81
+ box: unpacked(bundle.box, X_PKCS8),
96
82
  nextPkHash: bundle.nextPkHash,
97
- present: unpacked(bundle.present),
83
+ present: unpacked(bundle.present, ED_PKCS8),
98
84
  })
99
85
  : undefined;
100
86
  }
101
87
  return voiced({
102
- box: unpacked(bundle.box),
103
- next: unpacked(bundle.next),
104
- present: unpacked(bundle.present),
88
+ box: unpacked(bundle.box, X_PKCS8),
89
+ next: unpacked(bundle.next, ED_PKCS8),
90
+ present: unpacked(bundle.present, ED_PKCS8),
105
91
  });
106
92
  } catch {
107
93
  return undefined;
108
94
  }
109
95
  },
110
- sealTo: (boxPk, value) => {
111
- const ephemeral = generateKeyPairSync('x25519');
112
- return `${rawPk(ephemeral.privateKey).toString('base64')}.${Tools.seal(
113
- agreed(ephemeral.privateKey, boxPk),
114
- value,
115
- )}`;
96
+ sealTo: (boxPk, value, cipher) => {
97
+ const ephemeral = seeded();
98
+ const shut = cipher?.seal(agreed(ephemeral, boxPk), value);
99
+ return shut === undefined
100
+ ? undefined
101
+ : `${Bytes.base64(x25519.getPublicKey(ephemeral))}.${shut}`;
116
102
  },
117
103
  },
118
104
  ),