Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes to the `nirium-cli` package are documented here.
### Added

- `nirium anchor` ([#133](https://github.com/nirium-protocol/nirium/issues/133)): anchors a sha-256 hash (`--hash`), a file's sha-256 (`--file`, the file itself is never uploaded), or a small JSON record (`--record`, max 8KB, enforced client-side) to IPFS via an Audit Trail node's `POST /api/audit/log`. Optional `--tx`/`--tag`; no `--network` flag by design - the agent box decides its own network. Prints the CID, content hash, gateway URL, and a `nirium verify` suggestion.
- `nirium anchor --sign` ([#134](https://github.com/nirium-protocol/nirium/issues/134)): attaches an ed25519 attestation of which key produced the anchor. Signs `nirium-audit-v1:<content_sha256>` locally with a key from `--key-env <VAR>` or the config store's `secretKey` - the secret key itself never leaves this process. A rejected signature gets a `400` and anchors nothing; `nirium verify` reports `SIGNATURE: VALID` on the round trip.
- `nirium verify --gateway` is now repeatable, tried in order as fallbacks, with `--retries`/`--timeout` ([#135](https://github.com/nirium-protocol/nirium/issues/135)). A `404` retries the *same* gateway with backoff (a fresh pin can take a moment to propagate); any other failure moves straight to the next gateway. When every gateway 404s, that's reported as `availability: "not_yet_available"`, distinct from a real hash mismatch - nothing was fetched to compare in the first place.
- `nirium report summary` and `nirium report export anchors|payments|payroll` ([#136](https://github.com/nirium-protocol/nirium/issues/136)): thin client for the Reporting node. `export anchors` prints exactly one CID per line on stdout (nothing else), pipeable straight into `nirium verify`; the row count and the "not certified regulatory reporting" disclaimer print to stderr so they never land in that pipe. `--csv <file>` writes the server's CSV instead; `--json` prints the full response.

Expand Down
19 changes: 19 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,13 +235,32 @@ client-supplied network field is rejected server-side anyway.
| `--record <json>` | anchor this small JSON object verbatim | none |
| `--tx <hash>` | associate a transaction hash | none |
| `--tag <label>` | free-form label | none |
| `--sign` | attach an ed25519 attestation of which key produced this anchor | off |
| `--key-env <VAR>` | env var holding the secret key to sign with (`--sign` only) | config store's `secretKey` |
| `-u, --url <url>` | agent URL | `http://localhost:3002` |
| `--json` | machine-readable result | off |

On success, prints the CID, the content hash, the gateway URL, and a
`nirium verify <cid>` suggestion so the anchor can be independently
re-checked right away.

### Attesting who produced an anchor

```bash
nirium config set secretKey # hidden prompt, stored in ~/.niriumrc.json
nirium anchor --hash <64-hex> --sign
# or, without touching the config store:
NIRIUM_KEY=S... nirium anchor --hash <64-hex> --sign --key-env NIRIUM_KEY
```

The CID alone proves the anchored fact wasn't altered; `--sign` proves
*who* declared it. The secret key signs `nirium-audit-v1:<content_sha256>`
locally with ed25519 - only the resulting signature and the derived
public key (`G...`) ever leave this process, never the secret itself. If
the node rejects the signature, nothing is anchored (`400`, exit non-zero).
`nirium verify <cid>` independently re-checks it afterward and reports
`SIGNATURE: VALID` against the same public key.

## Verify an audit record

```bash
Expand Down
49 changes: 45 additions & 4 deletions packages/cli/bin/nirium.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ program
.option('--record <json>', 'Small JSON object (max 8KB) to anchor verbatim, e.g. \'{"order":"A-12"}\'')
.option('--tx <hash>', 'Transaction hash to associate with this anchor')
.option('--tag <tag>', 'Free-form label for this anchor')
.option('--sign', 'Attach an ed25519 attestation of which key produced this anchor')
.option('--key-env <VAR>', 'Environment variable holding the Stellar secret key to sign with (--sign only; default: the config store\'s secretKey)')
.option('-u, --url <url>', 'Agent URL', 'http://localhost:3002')
.option('--json', 'Output result as JSON')
.action(async (options) => {
Expand Down Expand Up @@ -1895,10 +1897,49 @@ async function executeAnchorCommand(options) {

// Returns the `agent` object for the request body, `undefined` when
// `--sign` was not passed, or the string 'failed' when fail() already
// ran (caller must return without doing anything else — placeholder for
// #134, which adds `--sign`/`--key-env` on top of this command).
async function maybeBuildAgentAttestation(_options, _body, _fail) {
return undefined;
// ran (caller must return without doing anything else).
//
// Signs locally and never sends the secret key anywhere - only the
// resulting signature and the derived public key leave this process.
// The statement signed, `nirium-audit-v1:<content_sha256>`, must match
// exactly what the server itself computes as contentSha before it
// verifies: the hash as-is for `--hash`/`--file` (already normalized to
// lowercase hex at this point), or sha-256 of the record's JSON text for
// `--record` - never the file's own content, which the server never sees.
async function maybeBuildAgentAttestation(options, body, fail) {
if (!options.sign) return undefined;

let secretKey;
if (options.keyEnv !== undefined) {
secretKey = process.env[options.keyEnv];
if (!secretKey) {
fail(`--key-env ${options.keyEnv} is not set (or is empty) in the environment.`);
return 'failed';
}
} else {
secretKey = loadConfig().secretKey;
if (!secretKey) {
fail('--sign needs a key: run `nirium config set secretKey`, or pass --key-env <VAR> '
+ 'with the secret key in that environment variable.');
return 'failed';
}
}

let keypair;
try {
keypair = Keypair.fromSecret(secretKey);
} catch (err) {
fail(`--sign: invalid Stellar secret key - ${err?.message || String(err)}`);
return 'failed';
}

const contentSha = body.hash
? body.hash
: crypto.createHash('sha256').update(JSON.stringify(body.record)).digest('hex');
const statement = `nirium-audit-v1:${contentSha}`;
const signature = Buffer.from(keypair.sign(Buffer.from(statement, 'utf8'))).toString('base64');

return { key: keypair.publicKey(), signature };
}

// ═══════════════════════════════════════════════════════════════
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"scripts": {
"prepublishOnly": "node --check bin/nirium.js",
"test": "node --test test/doctor-seller.test.js test/config-secret.test.js test/serve-api-key.test.js test/verify-hash-only.test.js test/verify-retry-gateway.test.js test/pay-agent-construction.test.js test/pay-status-and-cap.test.js test/status.test.js test/anchor.test.js test/report.test.js"
"test": "node --test test/doctor-seller.test.js test/config-secret.test.js test/serve-api-key.test.js test/verify-hash-only.test.js test/verify-retry-gateway.test.js test/pay-agent-construction.test.js test/pay-status-and-cap.test.js test/status.test.js test/anchor.test.js test/anchor-sign.test.js test/report.test.js"
},
"keywords": [
"stellar",
Expand Down
215 changes: 215 additions & 0 deletions packages/cli/test/anchor-sign.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* `nirium anchor --sign` (#134): attaches an agent attestation signed
* locally with a throwaway Keypair.random(). The fake "agent" server
* here verifies the signature exactly like the real node does (ed25519
* over `nirium-audit-v1:<content_sha256>`), so a genuinely bad
* signature gets a real 400 - not a stub that always accepts.
*/
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import path from 'node:path';
import { spawn } from 'node:child_process';
import http from 'node:http';
import { once } from 'node:events';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { Keypair, StrKey } from '@stellar/stellar-sdk';

const cliRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const cli = path.join(cliRoot, 'bin/nirium.js');

function verifyAgentSignature(gAddress, statement, signatureB64) {
try {
const rawPubKey = Buffer.from(StrKey.decodeEd25519PublicKey(gAddress));
const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex');
const keyObject = crypto.createPublicKey({
key: Buffer.concat([spkiHeader, rawPubKey]),
format: 'der',
type: 'spki',
});
const sigBuf = Buffer.from(signatureB64, 'base64');
if (sigBuf.length !== 64) return false;
return crypto.verify(null, Buffer.from(statement, 'utf8'), keyObject, sigBuf);
} catch {
return false;
}
}

let lastRequest = null;
let docsByCid = {};
let cidCounter = 0;
let forceSignatureReject = false;

const agentServer = http.createServer((req, res) => {
let chunks = [];
req.on('data', (d) => chunks.push(d));
req.on('end', () => {
let body;
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { body = null; }
lastRequest = body;

const contentSha = body?.hash
? String(body.hash).toLowerCase().replace(/^sha-?256:/, '')
: crypto.createHash('sha256').update(JSON.stringify(body?.record ?? {})).digest('hex');

if (body?.agent) {
const statement = `nirium-audit-v1:${contentSha}`;
const valid = !forceSignatureReject && verifyAgentSignature(body.agent.key, statement, body.agent.signature);
if (!valid) {
res.writeHead(400, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'agent attestation signature is invalid' }));
return;
}
}

cidCounter += 1;
const cid = `QmFakeSignTest${cidCounter}`;
const doc = {
content_sha256: contentSha,
anchoredAt: '2026-11-08T00:00:00.000Z',
...(body?.record !== undefined ? { record: body.record } : {}),
...(body?.agent ? { agent: { key: body.agent.key, signature: body.agent.signature } } : {}),
};
docsByCid[cid] = doc;

res.writeHead(201, { 'content-type': 'application/json' });
res.end(JSON.stringify({
cid,
gatewayUrl: `${gatewayBase}/ipfs/${cid}`,
content_sha256: contentSha,
...(body?.agent ? { attestedBy: body.agent.key } : {}),
anchoredAt: doc.anchoredAt,
}));
});
});

const gatewayServer = http.createServer((req, res) => {
const cid = decodeURIComponent(req.url.replace(/^\/ipfs\//, ''));
const doc = docsByCid[cid];
if (!doc) { res.writeHead(404).end(); return; }
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(doc));
});

let agentBase;
let gatewayBase;

test.before(async () => {
agentServer.listen(0, '127.0.0.1');
await once(agentServer, 'listening');
agentBase = `http://127.0.0.1:${agentServer.address().port}`;

gatewayServer.listen(0, '127.0.0.1');
await once(gatewayServer, 'listening');
gatewayBase = `http://127.0.0.1:${gatewayServer.address().port}`;
});

test.after(() => Promise.all([
new Promise((resolve, reject) => agentServer.close((err) => (err ? reject(err) : resolve()))),
new Promise((resolve, reject) => gatewayServer.close((err) => (err ? reject(err) : resolve()))),
]));

test.beforeEach(() => { lastRequest = null; docsByCid = {}; forceSignatureReject = false; });

function run(args, env = {}) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cli, ...args], {
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('timed out')); }, 10_000);
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
child.on('error', reject);
});
}

const HASH = 'a'.repeat(64);

test('round trip: --sign with --key-env, then `verify` reports SIGNATURE: VALID', async () => {
const kp = Keypair.random();
const anchorResult = await run(
['anchor', '--url', agentBase, '--hash', HASH, '--sign', '--key-env', 'TEST_SECRET', '--json'],
{ TEST_SECRET: kp.secret() },
);
assert.equal(anchorResult.code, 0, anchorResult.stdout + anchorResult.stderr);
const anchored = JSON.parse(anchorResult.stdout);
assert.equal(anchored.attestedBy, kp.publicKey());
assert.equal(lastRequest.agent.key, kp.publicKey());

const verifyResult = await run(['verify', anchored.cid, '--gateway', gatewayBase, '--json']);
assert.equal(verifyResult.code, 0, verifyResult.stdout + verifyResult.stderr);
const verified = JSON.parse(verifyResult.stdout);
assert.equal(verified.signatureStatus, 'valid');
assert.equal(verified.signerKey, kp.publicKey());
assert.equal(verified.ok, true);
});

test('round trip works with --record too (statement hashes the record JSON, not the hash field)', async () => {
const kp = Keypair.random();
const anchorResult = await run(
['anchor', '--url', agentBase, '--record', '{"order":"A-12"}', '--sign', '--key-env', 'TEST_SECRET', '--json'],
{ TEST_SECRET: kp.secret() },
);
assert.equal(anchorResult.code, 0, anchorResult.stdout + anchorResult.stderr);
const anchored = JSON.parse(anchorResult.stdout);

const verifyResult = await run(['verify', anchored.cid, '--gateway', gatewayBase, '--json']);
assert.equal(verifyResult.code, 0, verifyResult.stdout + verifyResult.stderr);
const verified = JSON.parse(verifyResult.stdout);
assert.equal(verified.signatureStatus, 'valid');
assert.equal(verified.mode, 'record');
assert.equal(verified.hashMatch, true);
});

test('a real bad signature (server rejects it) gets 400, nothing anchored', async () => {
forceSignatureReject = true;
const kp = Keypair.random();
const r = await run(
['anchor', '--url', agentBase, '--hash', HASH, '--sign', '--key-env', 'TEST_SECRET', '--json'],
{ TEST_SECRET: kp.secret() },
);
assert.equal(r.code, 1);
const json = JSON.parse(r.stdout);
assert.equal(json.ok, false);
assert.match(json.error, /invalid/);
assert.equal(Object.keys(docsByCid).length, 0);
});

test('--sign with a malformed secret key fails locally, never reaches the network', async () => {
const r = await run(
['anchor', '--url', agentBase, '--hash', HASH, '--sign', '--key-env', 'TEST_SECRET', '--json'],
{ TEST_SECRET: 'not-a-real-secret-key' },
);
assert.equal(r.code, 1);
const json = JSON.parse(r.stdout);
assert.equal(json.ok, false);
assert.match(json.error, /invalid Stellar secret key/i);
assert.equal(lastRequest, null);
});

test('--sign with --key-env pointing at an unset variable fails locally with a clear message', async () => {
const r = await run(['anchor', '--url', agentBase, '--hash', HASH, '--sign', '--key-env', 'NIRIUM_TEST_DOES_NOT_EXIST', '--json']);
assert.equal(r.code, 1);
const json = JSON.parse(r.stdout);
assert.equal(json.ok, false);
assert.match(json.error, /NIRIUM_TEST_DOES_NOT_EXIST/);
assert.equal(lastRequest, null);
});

test('--sign without --key-env and no secretKey in the config store fails with guidance, never reaches the network', async () => {
const r = await run(['anchor', '--url', agentBase, '--hash', HASH, '--sign', '--json'], { HOME: '/nonexistent-home-dir-for-test' });
assert.equal(r.code, 1);
const json = JSON.parse(r.stdout);
assert.equal(json.ok, false);
assert.match(json.error, /config set secretKey/);
assert.equal(lastRequest, null);
});

test('without --sign, the request body has no `agent` field at all (no behavior change)', async () => {
const r = await run(['anchor', '--url', agentBase, '--hash', HASH, '--json']);
assert.equal(r.code, 0, r.stdout + r.stderr);
assert.equal(lastRequest.agent, undefined);
});
Loading