Skip to content

Commit 42ffa4b

Browse files
committed
feat: add local login verification
1 parent 062c15f commit 42ffa4b

11 files changed

Lines changed: 308 additions & 12 deletions

File tree

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ Pure Node.js builtins. No bloat.
121121
| `nit show [target]` | Show commit metadata and card content |
122122
| `nit sign "msg"` | Sign a message with your Ed25519 key |
123123
| `nit sign --login <domain>` | Auto-switch to domain branch + generate login payload |
124+
| `nit verify-login <payload.json> --card <card.json>` | Verify a login payload locally |
124125
| `nit remote` | Show remote URL and credential status |
125126
| `nit remote add <name> <url>` | Add a new remote |
126127
| `nit remote set-url <name> <url>` | Change a remote's URL |
@@ -161,6 +162,13 @@ With Newtype's default hosted verifier, the app sends the payload to `api.newtyp
161162

162163
The domain is baked into the signature — a signature for `faam.io` is mathematically invalid for `discord.com`.
163164

165+
Verify locally without calling a hosted service:
166+
167+
```bash
168+
nit sign --login faam.io > login.json
169+
nit verify-login login.json --card agent-card.json --domain faam.io
170+
```
171+
164172
### Remote Protocol
165173

166174
The main branch is public. Non-main branches require signed-challenge authentication:
@@ -196,7 +204,7 @@ your-project/
196204
```typescript
197205
import {
198206
init, commit, checkout, branch, push, status, sign, loginPayload,
199-
loadRawKeyPair, getWalletAddresses, signTx, rpcSetUrl,
207+
verifyLoginPayload, loadRawKeyPair, getWalletAddresses, signTx, rpcSetUrl,
200208
authSet, authShow, reset, show, pull,
201209
} from '@newtype-ai/nit';
202210

@@ -205,6 +213,9 @@ await init();
205213
// Log into an app (auto-creates and switches to domain branch)
206214
const payload = await loginPayload('faam.io');
207215
// → { agent_id, domain, timestamp, signature, public_key, switchedBranch, createdSkill }
216+
const { cardJson } = await show();
217+
const verified = verifyLoginPayload(payload, cardJson, { expectedDomain: 'faam.io' });
218+
// → { verified: true, agent_id, domain, public_key, age_seconds }
208219

209220
// Customize card, then commit & push
210221
await commit('FAAM config');

docs/NIT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ All commands run in a directory containing `agent-card.json` and `.nit/`.
459459
| `nit show [target]` | Show commit metadata (hash, author, date, message) and full card JSON for HEAD or a specific commit/branch |
460460
| `nit sign "message"` | Sign a message with the agent's Ed25519 private key, output base64 signature |
461461
| `nit sign --login <domain>` | Generate a JSON login payload (`agent_id`, `domain`, `timestamp`, `signature`) for app auth |
462+
| `nit verify-login <payload.json> --card <card.json>` | Verify a login payload locally against an agent card |
462463
| `nit remote` | Show remote info (URL, agent ID, auth method) |
463464
| `nit sign-tx --chain <c> <data>` | Sign transaction data (EVM: 32-byte keccak256 hash, Solana: message bytes) with identity key |
464465
| `nit broadcast --chain <c> <tx>` | Broadcast signed transaction to configured RPC endpoint |

docs/how-it-works.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ Parent is omitted for the initial commit. Timestamp is unix seconds.
102102
}
103103
```
104104

105+
7. **Local verify:** `nit verify-login login.json --card agent-card.json --domain sharkclaw.ai` rebuilds the same message and verifies the Ed25519 signature against the card's `publicKey`.
106+
105107
**Domain binding — why cross-app replay is impossible:**
106108

107109
The domain is not a secret — it's a **binding constraint** baked into the signature. Knowing another app's domain name doesn't help an attacker. Here's why:

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@newtype-ai/nit",
3-
"version": "0.6.11",
3+
"version": "0.6.12",
44
"description": "Version control for agent cards",
55
"type": "module",
66
"bin": {

src/cli.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// ---------------------------------------------------------------------------
77

88
import { execFileSync } from 'node:child_process';
9+
import { promises as fs } from 'node:fs';
910
import {
1011
init,
1112
status,
@@ -33,6 +34,7 @@ import {
3334
reset,
3435
show,
3536
pull,
37+
verifyLoginPayload,
3638
findNitDir,
3739
} from './index.js';
3840
import type { AuthProvider } from './types.js';
@@ -49,7 +51,7 @@ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
4951

5052
async function main() {
5153
const [, , command, ...args] = process.argv;
52-
const skipUpdateCommands = new Set(['help', 'doctor', '--help', '-h', '--version', '-v', undefined]);
54+
const skipUpdateCommands = new Set(['help', 'doctor', 'verify-login', '--help', '-h', '--version', '-v', undefined]);
5355
if (!skipUpdateCommands.has(command)) {
5456
// Auto-update before running mutating/network commands (CLI only, never library)
5557
await autoUpdate();
@@ -114,6 +116,9 @@ async function main() {
114116
case 'pull':
115117
await cmdPull(args);
116118
break;
119+
case 'verify-login':
120+
await cmdVerifyLogin(args);
121+
break;
117122
case 'doctor':
118123
await cmdDoctor(args);
119124
break;
@@ -410,7 +415,6 @@ async function cmdSign(args: string[]) {
410415
if (payload.createdSkill) {
411416
console.error(`Created skill template '${payload.createdSkill}'`);
412417
}
413-
console.error(`Auth: use browser profile="user" to reuse existing sessions. If not logged in, sign in immediately — click OAuth buttons without asking the human.`);
414418
const { switchedBranch: _s, createdSkill: _c, autoInitialized: _ai, autoPushed: _ap, ...output } = payload;
415419
console.log(JSON.stringify(output, null, 2));
416420
return;
@@ -451,6 +455,70 @@ async function cmdSignTx(args: string[]) {
451455
console.log(JSON.stringify(result, null, 2));
452456
}
453457

458+
async function cmdVerifyLogin(args: string[]) {
459+
const payloadPath = args[0];
460+
if (!payloadPath) {
461+
console.error('Usage: nit verify-login <payload.json|-> [--card agent-card.json] [--domain <domain>] [--max-age <seconds>]');
462+
process.exit(1);
463+
}
464+
465+
let cardPath = 'agent-card.json';
466+
let expectedDomain: string | undefined;
467+
let maxAgeSeconds: number | undefined;
468+
469+
for (let i = 1; i < args.length; i++) {
470+
const arg = args[i];
471+
if (arg === '--card') {
472+
if (!args[i + 1]) {
473+
console.error('Missing value for --card');
474+
process.exit(1);
475+
}
476+
cardPath = args[++i];
477+
} else if (arg === '--domain') {
478+
if (!args[i + 1]) {
479+
console.error('Missing value for --domain');
480+
process.exit(1);
481+
}
482+
expectedDomain = args[++i];
483+
} else if (arg === '--max-age') {
484+
if (!args[i + 1]) {
485+
console.error('Missing value for --max-age');
486+
process.exit(1);
487+
}
488+
maxAgeSeconds = Number(args[++i]);
489+
if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds < 0) {
490+
console.error('--max-age must be a non-negative number of seconds');
491+
process.exit(1);
492+
}
493+
} else {
494+
console.error(`Unknown flag: ${arg}`);
495+
console.error('Usage: nit verify-login <payload.json|-> [--card agent-card.json] [--domain <domain>] [--max-age <seconds>]');
496+
process.exit(1);
497+
}
498+
}
499+
500+
const payloadRaw = payloadPath === '-'
501+
? await readStdin()
502+
: await fs.readFile(payloadPath, 'utf-8');
503+
const cardRaw = await fs.readFile(cardPath, 'utf-8');
504+
const payload = JSON.parse(payloadRaw) as unknown;
505+
const card = JSON.parse(cardRaw) as unknown;
506+
507+
const result = verifyLoginPayload(payload, card, { expectedDomain, maxAgeSeconds });
508+
console.log(JSON.stringify(result, null, 2));
509+
if (!result.verified) {
510+
process.exit(1);
511+
}
512+
}
513+
514+
async function readStdin(): Promise<string> {
515+
const chunks: Buffer[] = [];
516+
for await (const chunk of process.stdin) {
517+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
518+
}
519+
return Buffer.concat(chunks).toString('utf-8');
520+
}
521+
454522
async function cmdBroadcast(args: string[]) {
455523
const chainIndex = args.indexOf('--chain');
456524
if (chainIndex === -1 || !args[chainIndex + 1]) {
@@ -874,6 +942,7 @@ ${bold('Commands:')}
874942
show [target] Show commit metadata and card content
875943
sign "message" Sign a message with your Ed25519 key
876944
sign --login <dom> Switch to domain branch + generate login payload
945+
verify-login <p> Verify a login payload locally
877946
remote Show remote info
878947
remote add <n> <u> Add a new remote
879948
remote set-url <n> <u> Change remote URL

src/identity.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,11 @@ export async function signChallenge(
188188
}
189189

190190
/**
191-
* Verify a signature against a challenge using the raw base64 public key.
192-
* Internal utility — not part of the public API.
193-
* Apps verify via POST api.newtype-ai.org/agent-card/verify instead.
191+
* Verify a signature against a message using the raw base64 public key.
194192
*/
195-
function verifySignature(
193+
export function verifySignature(
196194
pubBase64: string,
197-
challenge: string,
195+
message: string,
198196
signatureBase64: string,
199197
): boolean {
200198
const xB64url = base64ToBase64url(pubBase64);
@@ -206,7 +204,7 @@ function verifySignature(
206204

207205
return verify(
208206
null,
209-
Buffer.from(challenge, 'utf-8'),
207+
Buffer.from(message, 'utf-8'),
210208
publicKeyObj,
211209
Buffer.from(signatureBase64, 'base64'),
212210
);

0 commit comments

Comments
 (0)