nervur 0.2.0 → 0.5.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 ADDED
@@ -0,0 +1,139 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ createPublicKey,
6
+ randomBytes,
7
+ verify,
8
+ } from 'node:crypto';
9
+
10
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
11
+
12
+ const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
13
+
14
+ const VERSION_G = 6 << 3;
15
+
16
+ const ordered = (value) =>
17
+ value === null || typeof value !== 'object'
18
+ ? value
19
+ : Array.isArray(value)
20
+ ? value.map(ordered)
21
+ : Object.fromEntries(
22
+ Object.keys(value)
23
+ .sort()
24
+ .map((key) => [key, ordered(value[key])]),
25
+ );
26
+
27
+ const crc16 = (bytes) => {
28
+ let crc = 0;
29
+ for (const b of bytes) {
30
+ crc ^= b << 8;
31
+ for (let i = 0; i < 8; i += 1) {
32
+ crc = (crc & 0x8000 ? (crc << 1) ^ 0x1021 : crc << 1) & 0xffff;
33
+ }
34
+ }
35
+ return crc;
36
+ };
37
+
38
+ const base32 = (bytes) => {
39
+ let bits = 0;
40
+ let value = 0;
41
+ let out = '';
42
+ for (const b of bytes) {
43
+ value = (value << 8) | b;
44
+ bits += 8;
45
+ while (bits >= 5) {
46
+ out += B32[(value >>> (bits - 5)) & 31];
47
+ bits -= 5;
48
+ }
49
+ }
50
+ return out;
51
+ };
52
+
53
+ const unbase32 = (text) => {
54
+ let bits = 0;
55
+ let value = 0;
56
+ const out = [];
57
+ for (const c of text) {
58
+ const index = B32.indexOf(c);
59
+ if (index < 0) {
60
+ return null;
61
+ }
62
+ value = (value << 5) | index;
63
+ bits += 5;
64
+ if (bits >= 8) {
65
+ out.push((value >>> (bits - 8)) & 0xff);
66
+ bits -= 8;
67
+ }
68
+ }
69
+ return Buffer.from(out);
70
+ };
71
+
72
+ const Tools = Object.freeze({
73
+ address: (pk) => {
74
+ const payload = Buffer.concat([Buffer.from([VERSION_G]), pk]);
75
+ const crc = crc16(payload);
76
+ return base32(Buffer.concat([payload, Buffer.from([crc & 0xff, crc >> 8])]));
77
+ },
78
+ canon: (claim) => Buffer.from(JSON.stringify(ordered(claim))),
79
+ derive: (root, label) => createHash('sha256').update(root).update(label).digest(),
80
+ digest: (bytes) => createHash('sha256').update(bytes).digest('hex'),
81
+ envelope: (voice, to, op, seq) => {
82
+ const from = Tools.address(voice.pk);
83
+ return { from, op, seq, sig: voice.sign(Tools.canon({ from, op, seq, to })), to };
84
+ },
85
+ pkOf: (address) => {
86
+ const bytes = typeof address === 'string' && address.length === 56 ? unbase32(address) : null;
87
+ if (bytes === null || bytes[0] !== VERSION_G) {
88
+ return null;
89
+ }
90
+ const payload = bytes.subarray(0, 33);
91
+ const checksum = bytes[33] | (bytes[34] << 8);
92
+ return crc16(payload) === checksum ? Buffer.from(payload.subarray(1)) : null;
93
+ },
94
+ seal: (secret, value) => {
95
+ const iv = randomBytes(12);
96
+ const cipher = createCipheriv('aes-256-gcm', secret, iv);
97
+ const sealed = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
98
+ return Buffer.concat([iv, cipher.getAuthTag(), sealed]).toString('base64');
99
+ },
100
+ secret: () => randomBytes(32),
101
+ unseal: (secret, sealed) => {
102
+ try {
103
+ const bytes = Buffer.from(sealed, 'base64');
104
+ const decipher = createDecipheriv('aes-256-gcm', secret, bytes.subarray(0, 12));
105
+ decipher.setAuthTag(bytes.subarray(12, 28));
106
+ return JSON.parse(
107
+ Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString('utf8'),
108
+ );
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ },
113
+ verifies: (pk, bytes, sig) => {
114
+ try {
115
+ return verify(
116
+ null,
117
+ bytes,
118
+ createPublicKey({
119
+ key: Buffer.concat([ED25519_SPKI_PREFIX, pk]),
120
+ format: 'der',
121
+ type: 'spki',
122
+ }),
123
+ Buffer.from(sig, 'base64'),
124
+ );
125
+ } catch {
126
+ return false;
127
+ }
128
+ },
129
+ });
130
+
131
+ const Stamp = () => {
132
+ let last = 0;
133
+ return () => {
134
+ last = Math.max(last + 1, Date.now());
135
+ return last;
136
+ };
137
+ };
138
+
139
+ export { Stamp, Tools };
package/voice.mjs ADDED
@@ -0,0 +1,90 @@
1
+ import {
2
+ createHash,
3
+ createPrivateKey,
4
+ createPublicKey,
5
+ diffieHellman,
6
+ generateKeyPairSync,
7
+ sign,
8
+ } from 'node:crypto';
9
+ import { Tools } from './tools.mjs';
10
+
11
+ const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
12
+
13
+ const packed = (privateKey) =>
14
+ privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64');
15
+
16
+ const unpacked = (text) =>
17
+ createPrivateKey({ key: Buffer.from(text, 'base64'), format: 'der', type: 'pkcs8' });
18
+
19
+ const rawPk = (privateKey) =>
20
+ createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).subarray(-32);
21
+
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();
35
+
36
+ const voiced = ({ present, next, box }) =>
37
+ Object.freeze({
38
+ boxPk: rawPk(box),
39
+ nextPkHash: Tools.digest(rawPk(next)),
40
+ open: (wrapped) => {
41
+ try {
42
+ const [ephemeral, sealed] = wrapped.split('.');
43
+ return Tools.unseal(agreed(box, Buffer.from(ephemeral, 'base64')), sealed);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ },
48
+ pack: () => ({ box: packed(box), next: packed(next), present: packed(present) }),
49
+ pk: rawPk(present),
50
+ sign: (bytes) => sign(null, bytes, present).toString('base64'),
51
+ succeed: () =>
52
+ voiced({
53
+ box: generateKeyPairSync('x25519').privateKey,
54
+ next: generateKeyPairSync('ed25519').privateKey,
55
+ present: next,
56
+ }),
57
+ });
58
+
59
+ const Voice = Object.freeze(
60
+ Object.assign(
61
+ () =>
62
+ voiced({
63
+ box: generateKeyPairSync('x25519').privateKey,
64
+ next: generateKeyPairSync('ed25519').privateKey,
65
+ present: generateKeyPairSync('ed25519').privateKey,
66
+ }),
67
+ {
68
+ revive: (bundle) => {
69
+ try {
70
+ return voiced({
71
+ box: unpacked(bundle.box),
72
+ next: unpacked(bundle.next),
73
+ present: unpacked(bundle.present),
74
+ });
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ },
79
+ sealTo: (boxPk, value) => {
80
+ const ephemeral = generateKeyPairSync('x25519');
81
+ return `${rawPk(ephemeral.privateKey).toString('base64')}.${Tools.seal(
82
+ agreed(ephemeral.privateKey, boxPk),
83
+ value,
84
+ )}`;
85
+ },
86
+ },
87
+ ),
88
+ );
89
+
90
+ export { Voice };
package/completion.js DELETED
@@ -1,122 +0,0 @@
1
- // nervur — shell completion (`nervur completion [bash|zsh]`). Client-local like
2
- // scaffold: touches no carcass. One verb table is the single truth; both scripts
3
- // are generated from it so a new verb lands in completion by editing one list.
4
- // No prompt anywhere: the shell is a positional with a sane default ($SHELL).
5
-
6
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
- import { homedir } from 'node:os'
8
- import { join } from 'node:path'
9
-
10
- // verb → one-line description; subs/kinds drive second-position completion.
11
- export const VERBS = [
12
- ['install', 'stand a published carcass at NERVUR_HOME'],
13
- ['up', '(dev bench) preflight, then stand the ground'],
14
- ['down', 'stop the ground'],
15
- ['reset', 'stop the ground and wipe its data'],
16
- ['preflight', 'run the bench preflight and report'],
17
- ['whoami', 'ask the carcass who it is'],
18
- ['status', 'ask the carcass for its status'],
19
- ['identity', 'manage identities (add · key · list · remove)'],
20
- ['use', 'select the identity the verbs act as'],
21
- ['add', 'add a being under the selected identity'],
22
- ['list', 'list the beings on the ground'],
23
- ['remove', 'remove a being'],
24
- ['scaffold', 'mint a species repo from a template'],
25
- ['deploy', 'grant + pin + run a species'],
26
- ['species', 'list deployed species and their pins'],
27
- ['completion', 'print the shell completion script (bash · zsh)'],
28
- ['help', 'show help']
29
- ]
30
-
31
- const names = VERBS.map(([v]) => v).join(' ')
32
-
33
- const bash = () => `# nervur completion (bash)
34
- # load now: eval "$(nervur completion bash)"
35
- # persist: nervur completion bash >> ~/.bashrc (or into bash_completion.d)
36
- _nervur() {
37
- local cur prev
38
- cur="\${COMP_WORDS[COMP_CWORD]}"
39
- prev="\${COMP_WORDS[COMP_CWORD-1]}"
40
- case "$prev" in
41
- identity) COMPREPLY=($(compgen -W "add key list remove" -- "$cur")); return ;;
42
- completion) COMPREPLY=($(compgen -W "bash zsh" -- "$cur")); return ;;
43
- add|--kind) COMPREPLY=($(compgen -W "empty" -- "$cur")); return ;;
44
- --dir) COMPREPLY=($(compgen -d -- "$cur")); return ;;
45
- esac
46
- case "$cur" in
47
- --*) COMPREPLY=($(compgen -W "--as --no-door --kind --dir --key" -- "$cur")); return ;;
48
- esac
49
- if [ "$COMP_CWORD" -eq 1 ]; then
50
- COMPREPLY=($(compgen -W "${names}" -- "$cur"))
51
- fi
52
- }
53
- complete -F _nervur nervur
54
- `
55
-
56
- const zsh = () => `#compdef nervur
57
- # nervur completion (zsh)
58
- # load now: eval "$(nervur completion zsh)"
59
- # persist: nervur completion zsh > "\${fpath[1]}/_nervur" && rm -f ~/.zcompdump; compinit
60
- _nervur() {
61
- local -a verbs
62
- verbs=(
63
- ${VERBS.map(([v, d]) => ` '${v}:${d.replace(/'/g, "'\\''")}'`).join('\n')}
64
- )
65
- if (( CURRENT == 2 )); then
66
- _describe 'verb' verbs
67
- return
68
- fi
69
- case "\${words[2]}" in
70
- identity) (( CURRENT == 3 )) && _values 'sub' add key list remove ;;
71
- completion) (( CURRENT == 3 )) && _values 'shell' bash zsh ;;
72
- add) (( CURRENT == 3 )) && _values 'kind' empty ;;
73
- scaffold) _arguments '--kind[template kind]:kind:(empty)' '--dir[target directory]:dir:_directories' ;;
74
- up|preflight) _arguments '--no-door[stand on localhost only (headless/CI)]' ;;
75
- esac
76
- }
77
- if (( ! $+functions[compdef] )); then
78
- autoload -Uz compinit
79
- compinit
80
- fi
81
- compdef _nervur nervur
82
- `
83
-
84
- export function completionScript(shell) {
85
- const which = shell ?? (process.env.SHELL?.endsWith('zsh') ? 'zsh' : 'bash')
86
- if (which === 'zsh') return zsh()
87
- if (which === 'bash') return bash()
88
- throw new Error(`usage: nervur completion [bash|zsh] — '${which}' is not a shell I know`)
89
- }
90
-
91
- const MARK_OPEN = '# >>> nervur completion >>>'
92
- const MARK_CLOSE = '# <<< nervur completion <<<'
93
-
94
- // Wire completion for the operator's shell: the script lands as a file in the
95
- // nervur home and the rc file gains one marked source-block — sourcing a file
96
- // costs nothing per shell start, where `eval "$(nervur completion)"` would
97
- // spawn node every time. Idempotent by the marker block (the dev/hosts.sh
98
- // idiom): re-wiring rewrites the block in place, never stacks a second one.
99
- export function wireCompletion(home) {
100
- const shell = process.env.SHELL?.endsWith('zsh')
101
- ? 'zsh'
102
- : process.env.SHELL?.endsWith('bash')
103
- ? 'bash'
104
- : null
105
- if (!shell) return { wired: false }
106
- mkdirSync(home, { recursive: true })
107
- const scriptPath = join(home, `completion.${shell}`)
108
- writeFileSync(scriptPath, completionScript(shell))
109
- const rc = join(homedir(), shell === 'zsh' ? '.zshrc' : '.bashrc')
110
- const block = `${MARK_OPEN}\n[ -f "${scriptPath}" ] && source "${scriptPath}"\n${MARK_CLOSE}\n`
111
- const current = existsSync(rc) ? readFileSync(rc, 'utf8') : ''
112
- if (current.includes(MARK_OPEN)) {
113
- const next = current.replace(
114
- current.slice(current.indexOf(MARK_OPEN), current.indexOf(MARK_CLOSE) + MARK_CLOSE.length + 1),
115
- block
116
- )
117
- if (next !== current) writeFileSync(rc, next)
118
- } else {
119
- appendFileSync(rc, `${current && !current.endsWith('\n') ? '\n' : ''}\n${block}`)
120
- }
121
- return { wired: true, shell, rc, scriptPath }
122
- }