anyagent-bridge 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/.env.example +81 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/bin/anyagent-bridge.js +127 -0
- package/client/index.html +525 -0
- package/config.example.json +69 -0
- package/docs/INSTALL.md +138 -0
- package/docs/ROADMAP.md +168 -0
- package/docs/SECURITY.md +85 -0
- package/docs/WALKTHROUGH.md +82 -0
- package/docs/screenshots/.gitkeep +3 -0
- package/docs/screenshots/01-startup-banner.png +0 -0
- package/docs/screenshots/02-terminal-view.png +0 -0
- package/docs/screenshots/03-agent-running.png +0 -0
- package/docs/screenshots/04-mobile.png +0 -0
- package/package.json +57 -0
- package/server/auth/index.js +20 -0
- package/server/auth/manager.js +448 -0
- package/server/auth/oauth.js +154 -0
- package/server/auth/providers/github.js +59 -0
- package/server/auth/providers/google.js +44 -0
- package/server/auth/sessions.js +160 -0
- package/server/auth/store.js +135 -0
- package/server/auth/totp.js +140 -0
- package/server/index.js +1779 -0
- package/server/safety/audit.js +139 -0
- package/server/safety/clientip.js +73 -0
- package/server/safety/index.js +17 -0
- package/server/safety/manager.js +507 -0
- package/server/safety/redact.js +153 -0
- package/server/safety/sandbox.js +130 -0
- package/server/tunnel/adapters/cloudflare-quick.js +40 -0
- package/server/tunnel/adapters/cloudflared-named.js +49 -0
- package/server/tunnel/adapters/devtunnel.js +54 -0
- package/server/tunnel/adapters/tailscale.js +42 -0
- package/server/tunnel/base-adapter.js +185 -0
- package/server/tunnel/detect.js +65 -0
- package/server/tunnel/index.js +15 -0
- package/server/tunnel/manager.js +321 -0
- package/server/tunnel/registry.js +31 -0
- package/test/stage4-boot.js +98 -0
- package/test/stage4-smoke.js +267 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnyAgent Bridge — Google OAuth provider (Stage 3)
|
|
3
|
+
*
|
|
4
|
+
* OpenID Connect authorization-code flow with PKCE. Identity comes from the
|
|
5
|
+
* UserInfo endpoint using the freshly-issued access token (no JWT verification
|
|
6
|
+
* needed: the token was obtained directly from Google's token endpoint over TLS
|
|
7
|
+
* with the client secret). The allow-key is the verified email.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
id: 'google',
|
|
12
|
+
label: 'Google',
|
|
13
|
+
scope: 'openid email profile',
|
|
14
|
+
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
15
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
16
|
+
usePkce: true,
|
|
17
|
+
extraAuthParams: { access_type: 'online', prompt: 'select_account' },
|
|
18
|
+
|
|
19
|
+
async fetchIdentity(accessToken) {
|
|
20
|
+
const res = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
21
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) throw new Error(`Google userinfo ${res.status}`);
|
|
24
|
+
const u = await res.json();
|
|
25
|
+
return {
|
|
26
|
+
sub: u.sub,
|
|
27
|
+
email: u.email || null,
|
|
28
|
+
emailVerified: u.email_verified === true || u.email_verified === 'true',
|
|
29
|
+
name: u.name || u.email || null
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// What the operator allowlist matches against, and what gets claimed.
|
|
34
|
+
allowKey(identity) {
|
|
35
|
+
return identity.email ? String(identity.email).toLowerCase() : null;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// Reject identities that cannot be safely authorized.
|
|
39
|
+
validate(identity) {
|
|
40
|
+
if (!identity.email) return 'Google account has no email';
|
|
41
|
+
if (!identity.emailVerified) return 'Google email is not verified';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnyAgent Bridge — SessionStore (Stage 3)
|
|
3
|
+
*
|
|
4
|
+
* Issues and validates signed, expiring login sessions. A session token is
|
|
5
|
+
* stateless-signed (HMAC-SHA256 over the payload) AND tracked server-side by id,
|
|
6
|
+
* so it can be listed and revoked — logout and "sign out everywhere" are real,
|
|
7
|
+
* not cosmetic. Persisted to .data/auth-sessions.json so logins survive a
|
|
8
|
+
* server restart (mirrors sessions.json for terminals).
|
|
9
|
+
*
|
|
10
|
+
* Token format: base64url(JSON{id,sub,exp}) + "." + base64url(HMAC-SHA256)
|
|
11
|
+
* Validation requires: good signature, not expired, and id still present in the
|
|
12
|
+
* store (deleting the id == revoking the token).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
class SessionStore {
|
|
20
|
+
constructor({ secret, ttlMs, filePath, logger } = {}) {
|
|
21
|
+
if (!secret) throw new Error('SessionStore requires a signing secret');
|
|
22
|
+
this.secret = secret;
|
|
23
|
+
this.ttlMs = ttlMs || 12 * 60 * 60 * 1000;
|
|
24
|
+
this.filePath = filePath;
|
|
25
|
+
this.logger = logger || console;
|
|
26
|
+
this.sessions = new Map(); // id -> meta
|
|
27
|
+
this._load();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_sign(payload) {
|
|
31
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
32
|
+
const sig = crypto.createHmac('sha256', this.secret).update(body).digest('base64url');
|
|
33
|
+
return `${body}.${sig}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mint a new session for a principal. Returns { token, session }. */
|
|
37
|
+
mint({ sub, provider, name, email, ip, ttlMs } = {}) {
|
|
38
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
39
|
+
const iat = Date.now();
|
|
40
|
+
const exp = iat + (ttlMs || this.ttlMs);
|
|
41
|
+
const session = {
|
|
42
|
+
id,
|
|
43
|
+
sub: sub || 'unknown',
|
|
44
|
+
provider: provider || 'token',
|
|
45
|
+
name: name || null,
|
|
46
|
+
email: email || null,
|
|
47
|
+
ip: ip || null,
|
|
48
|
+
iat,
|
|
49
|
+
exp,
|
|
50
|
+
lastSeen: iat
|
|
51
|
+
};
|
|
52
|
+
this.sessions.set(id, session);
|
|
53
|
+
this._persist();
|
|
54
|
+
const token = this._sign({ id, sub: session.sub, exp });
|
|
55
|
+
return { token, session };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Verify a candidate token. Returns the live session meta on success, or null.
|
|
60
|
+
* Signature is checked BEFORE the payload is parsed (never trust unverified
|
|
61
|
+
* bytes). Touches lastSeen on success.
|
|
62
|
+
*/
|
|
63
|
+
verify(token) {
|
|
64
|
+
if (!token || typeof token !== 'string') return null;
|
|
65
|
+
const dot = token.indexOf('.');
|
|
66
|
+
if (dot <= 0 || dot === token.length - 1) return null;
|
|
67
|
+
|
|
68
|
+
const body = token.slice(0, dot);
|
|
69
|
+
const sig = token.slice(dot + 1);
|
|
70
|
+
const expected = crypto.createHmac('sha256', this.secret).update(body).digest('base64url');
|
|
71
|
+
const a = Buffer.from(sig);
|
|
72
|
+
const b = Buffer.from(expected);
|
|
73
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
74
|
+
|
|
75
|
+
let payload;
|
|
76
|
+
try {
|
|
77
|
+
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!payload || !payload.id || !payload.exp) return null;
|
|
82
|
+
if (Date.now() > payload.exp) { this._drop(payload.id); return null; }
|
|
83
|
+
|
|
84
|
+
const meta = this.sessions.get(payload.id);
|
|
85
|
+
if (!meta) return null; // revoked or unknown id
|
|
86
|
+
if (Date.now() > meta.exp) { this._drop(payload.id); return null; }
|
|
87
|
+
|
|
88
|
+
meta.lastSeen = Date.now();
|
|
89
|
+
return meta;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
revoke(id) {
|
|
93
|
+
const existed = this.sessions.delete(id);
|
|
94
|
+
if (existed) this._persist();
|
|
95
|
+
return existed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
list() {
|
|
99
|
+
this._pruneExpired();
|
|
100
|
+
return Array.from(this.sessions.values()).map(s => ({
|
|
101
|
+
id: s.id,
|
|
102
|
+
sub: s.sub,
|
|
103
|
+
provider: s.provider,
|
|
104
|
+
name: s.name,
|
|
105
|
+
email: s.email,
|
|
106
|
+
ip: s.ip,
|
|
107
|
+
iat: s.iat,
|
|
108
|
+
exp: s.exp,
|
|
109
|
+
lastSeen: s.lastSeen
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
count() {
|
|
114
|
+
this._pruneExpired();
|
|
115
|
+
return this.sessions.size;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_drop(id) {
|
|
119
|
+
if (this.sessions.delete(id)) this._persist();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_pruneExpired() {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
let changed = false;
|
|
125
|
+
for (const [id, meta] of this.sessions.entries()) {
|
|
126
|
+
if (now > meta.exp) { this.sessions.delete(id); changed = true; }
|
|
127
|
+
}
|
|
128
|
+
if (changed) this._persist();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_load() {
|
|
132
|
+
if (!this.filePath) return;
|
|
133
|
+
try {
|
|
134
|
+
if (fs.existsSync(this.filePath)) {
|
|
135
|
+
const data = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
|
|
136
|
+
const list = Array.isArray(data.sessions) ? data.sessions : [];
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
for (const s of list) {
|
|
139
|
+
if (s && s.id && s.exp && now < s.exp) this.sessions.set(s.id, s);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
this.logger.error(`[Auth] Failed to load sessions: ${e.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_persist() {
|
|
148
|
+
if (!this.filePath) return;
|
|
149
|
+
try {
|
|
150
|
+
const tmp = this.filePath + '.tmp';
|
|
151
|
+
const data = JSON.stringify({ sessions: Array.from(this.sessions.values()) }, null, 2);
|
|
152
|
+
fs.writeFileSync(tmp, data, { mode: 0o600 });
|
|
153
|
+
fs.renameSync(tmp, this.filePath); // atomic replace
|
|
154
|
+
} catch (e) {
|
|
155
|
+
this.logger.error(`[Auth] Failed to persist sessions: ${e.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = SessionStore;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnyAgent Bridge — AuthStore (Stage 3)
|
|
3
|
+
*
|
|
4
|
+
* Persists the long-lived auth state that is NOT a session:
|
|
5
|
+
* - TOTP enrollment (active secret + hashed one-time recovery codes)
|
|
6
|
+
* - a pending (not-yet-confirmed) TOTP secret during setup
|
|
7
|
+
* - OAuth "claimed" identities (first-user-claim / TOFU records)
|
|
8
|
+
*
|
|
9
|
+
* Written to .data/auth-users.json with 0600 perms and atomic replace. Secrets
|
|
10
|
+
* live here, never in the git-tracked config. The session signing secret is kept
|
|
11
|
+
* in a separate file so this one can be inspected without leaking it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
|
|
17
|
+
function hashCode(code) {
|
|
18
|
+
return crypto.createHash('sha256').update(String(code)).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class AuthStore {
|
|
22
|
+
constructor({ filePath, logger } = {}) {
|
|
23
|
+
this.filePath = filePath;
|
|
24
|
+
this.logger = logger || console;
|
|
25
|
+
this.data = {
|
|
26
|
+
totp: { confirmed: false, secret: null, confirmedAt: null, recoveryCodes: [], lastCounter: 0 },
|
|
27
|
+
totpPending: { secret: null, createdAt: null },
|
|
28
|
+
oauthClaimed: { google: [], github: [] }
|
|
29
|
+
};
|
|
30
|
+
this._load();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_load() {
|
|
34
|
+
if (!this.filePath) return;
|
|
35
|
+
try {
|
|
36
|
+
if (fs.existsSync(this.filePath)) {
|
|
37
|
+
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
|
|
38
|
+
this.data = {
|
|
39
|
+
totp: { confirmed: false, secret: null, confirmedAt: null, recoveryCodes: [], lastCounter: 0, ...(parsed.totp || {}) },
|
|
40
|
+
totpPending: { secret: null, createdAt: null, ...(parsed.totpPending || {}) },
|
|
41
|
+
oauthClaimed: { google: [], github: [], ...(parsed.oauthClaimed || {}) }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
this.logger.error(`[Auth] Failed to load auth store: ${e.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_persist() {
|
|
50
|
+
if (!this.filePath) return;
|
|
51
|
+
try {
|
|
52
|
+
const tmp = this.filePath + '.tmp';
|
|
53
|
+
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 });
|
|
54
|
+
fs.renameSync(tmp, this.filePath);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
this.logger.error(`[Auth] Failed to persist auth store: ${e.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── TOTP ───────────────────────────────────────────────────────────────────
|
|
61
|
+
get totpConfirmed() { return !!this.data.totp.confirmed; }
|
|
62
|
+
get totpSecret() { return this.data.totp.secret; }
|
|
63
|
+
|
|
64
|
+
setPendingTotp(secret) {
|
|
65
|
+
this.data.totpPending = { secret, createdAt: Date.now() };
|
|
66
|
+
this._persist();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getPendingTotp() { return this.data.totpPending.secret; }
|
|
70
|
+
|
|
71
|
+
/** Promote the pending secret to active and store hashed recovery codes. */
|
|
72
|
+
confirmTotp(secret, recoveryCodesPlain, initialCounter) {
|
|
73
|
+
this.data.totp = {
|
|
74
|
+
confirmed: true,
|
|
75
|
+
secret,
|
|
76
|
+
confirmedAt: Date.now(),
|
|
77
|
+
recoveryCodes: (recoveryCodesPlain || []).map(c => ({ hash: hashCode(c), used: false })),
|
|
78
|
+
// Baseline the replay counter to the code that confirmed enrollment, so that
|
|
79
|
+
// same code cannot be immediately replayed to log in.
|
|
80
|
+
lastCounter: Number.isFinite(initialCounter) ? initialCounter : 0
|
|
81
|
+
};
|
|
82
|
+
this.data.totpPending = { secret: null, createdAt: null };
|
|
83
|
+
this._persist();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
disableTotp() {
|
|
87
|
+
this.data.totp = { confirmed: false, secret: null, confirmedAt: null, recoveryCodes: [], lastCounter: 0 };
|
|
88
|
+
this.data.totpPending = { secret: null, createdAt: null };
|
|
89
|
+
this._persist();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Replay guard: the highest TOTP step counter accepted so far. A code at a
|
|
93
|
+
// counter <= this must be rejected as a replay (RFC 6238 §5.2).
|
|
94
|
+
getTotpLastCounter() { return this.data.totp.lastCounter || 0; }
|
|
95
|
+
setTotpLastCounter(counter) {
|
|
96
|
+
if (Number.isFinite(counter) && counter > (this.data.totp.lastCounter || 0)) {
|
|
97
|
+
this.data.totp.lastCounter = counter;
|
|
98
|
+
this._persist();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Consume a one-time recovery code (constant-time hash compare). */
|
|
103
|
+
useRecoveryCode(code) {
|
|
104
|
+
const target = Buffer.from(hashCode(String(code).replace(/\s+/g, '')), 'hex');
|
|
105
|
+
let match = null;
|
|
106
|
+
for (const rc of this.data.totp.recoveryCodes) {
|
|
107
|
+
if (rc.used) continue;
|
|
108
|
+
const h = Buffer.from(rc.hash, 'hex');
|
|
109
|
+
if (h.length === target.length && crypto.timingSafeEqual(h, target)) match = rc;
|
|
110
|
+
}
|
|
111
|
+
if (!match) return false;
|
|
112
|
+
match.used = true;
|
|
113
|
+
this._persist();
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
recoveryCodesRemaining() {
|
|
118
|
+
return this.data.totp.recoveryCodes.filter(rc => !rc.used).length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── OAuth claim (TOFU) ───────────────────────────────────────────────────────
|
|
122
|
+
getClaimed(provider) {
|
|
123
|
+
return Array.isArray(this.data.oauthClaimed[provider]) ? this.data.oauthClaimed[provider] : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
claim(provider, key) {
|
|
127
|
+
if (!this.data.oauthClaimed[provider]) this.data.oauthClaimed[provider] = [];
|
|
128
|
+
if (!this.data.oauthClaimed[provider].includes(key)) {
|
|
129
|
+
this.data.oauthClaimed[provider].push(key);
|
|
130
|
+
this._persist();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { AuthStore, hashCode };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnyAgent Bridge — TOTP (Stage 3)
|
|
3
|
+
*
|
|
4
|
+
* RFC 4226 (HOTP) + RFC 6238 (TOTP), implemented with only Node's `crypto`.
|
|
5
|
+
* No new npm dependency. Compatible with Google Authenticator / 1Password /
|
|
6
|
+
* Authy (SHA1, 6 digits, 30s period, base32 secret).
|
|
7
|
+
*
|
|
8
|
+
* Pure functions — no state, no I/O. The store/manager own persistence.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
// RFC 4648 base32 alphabet (no padding on encode; padding tolerated on decode).
|
|
14
|
+
const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
15
|
+
|
|
16
|
+
function base32Encode(buf) {
|
|
17
|
+
let bits = 0;
|
|
18
|
+
let value = 0;
|
|
19
|
+
let out = '';
|
|
20
|
+
for (let i = 0; i < buf.length; i++) {
|
|
21
|
+
value = (value << 8) | buf[i];
|
|
22
|
+
bits += 8;
|
|
23
|
+
while (bits >= 5) {
|
|
24
|
+
out += B32_ALPHABET[(value >>> (bits - 5)) & 31];
|
|
25
|
+
bits -= 5;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (bits > 0) {
|
|
29
|
+
out += B32_ALPHABET[(value << (5 - bits)) & 31];
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function base32Decode(str) {
|
|
35
|
+
const clean = String(str).toUpperCase().replace(/\s+/g, '').replace(/=+$/, '');
|
|
36
|
+
let bits = 0;
|
|
37
|
+
let value = 0;
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const ch of clean) {
|
|
40
|
+
const idx = B32_ALPHABET.indexOf(ch);
|
|
41
|
+
if (idx === -1) continue; // ignore anything outside the alphabet
|
|
42
|
+
value = (value << 5) | idx;
|
|
43
|
+
bits += 5;
|
|
44
|
+
if (bits >= 8) {
|
|
45
|
+
out.push((value >>> (bits - 8)) & 0xff);
|
|
46
|
+
bits -= 8;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return Buffer.from(out);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A fresh random base32 secret (default 20 bytes = 160 bits, RFC 4226 §4). */
|
|
53
|
+
function generateSecret(bytes = 20) {
|
|
54
|
+
return base32Encode(crypto.randomBytes(bytes));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** HOTP value for a given counter. Counter is treated as a 64-bit big-endian int. */
|
|
58
|
+
function hotp(secretBase32, counter, digits = 6) {
|
|
59
|
+
const key = base32Decode(secretBase32);
|
|
60
|
+
const buf = Buffer.alloc(8);
|
|
61
|
+
// Split into hi/lo 32-bit words so counters above 2^32 stay correct.
|
|
62
|
+
const hi = Math.floor(counter / 0x100000000);
|
|
63
|
+
const lo = counter % 0x100000000;
|
|
64
|
+
buf.writeUInt32BE(hi, 0);
|
|
65
|
+
buf.writeUInt32BE(lo, 4);
|
|
66
|
+
|
|
67
|
+
const hmac = crypto.createHmac('sha1', key).update(buf).digest();
|
|
68
|
+
const offset = hmac[hmac.length - 1] & 0x0f;
|
|
69
|
+
const bin = ((hmac[offset] & 0x7f) << 24) |
|
|
70
|
+
((hmac[offset + 1] & 0xff) << 16) |
|
|
71
|
+
((hmac[offset + 2] & 0xff) << 8) |
|
|
72
|
+
(hmac[offset + 3] & 0xff);
|
|
73
|
+
const otp = bin % Math.pow(10, digits);
|
|
74
|
+
return String(otp).padStart(digits, '0');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Current TOTP value. `opts.now` (ms) overridable for tests. */
|
|
78
|
+
function totp(secretBase32, opts = {}) {
|
|
79
|
+
const step = opts.step || 30;
|
|
80
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
81
|
+
const counter = Math.floor((now / 1000) / step);
|
|
82
|
+
return hotp(secretBase32, counter, opts.digits || 6);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Return the matched step counter for a user-supplied code (allowing ±`window`
|
|
87
|
+
* steps of drift), or -1 if it matches no step. Constant-time per candidate.
|
|
88
|
+
* The counter is what the caller persists to prevent replay (RFC 6238 §5.2):
|
|
89
|
+
* a code at counter <= the last accepted counter must be rejected.
|
|
90
|
+
*/
|
|
91
|
+
function matchCounter(secretBase32, code, opts = {}) {
|
|
92
|
+
if (!secretBase32 || code == null) return -1;
|
|
93
|
+
const cleaned = String(code).replace(/\s+/g, '');
|
|
94
|
+
if (!/^\d{6,8}$/.test(cleaned)) return -1;
|
|
95
|
+
|
|
96
|
+
const step = opts.step || 30;
|
|
97
|
+
const digits = opts.digits || 6;
|
|
98
|
+
const window = opts.window != null ? opts.window : 1;
|
|
99
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
100
|
+
const counter = Math.floor((now / 1000) / step);
|
|
101
|
+
|
|
102
|
+
const candidate = Buffer.from(cleaned);
|
|
103
|
+
for (let i = -window; i <= window; i++) {
|
|
104
|
+
if (counter + i < 0) continue; // negative counters are not valid TOTP steps
|
|
105
|
+
const expected = Buffer.from(hotp(secretBase32, counter + i, digits));
|
|
106
|
+
if (expected.length === candidate.length && crypto.timingSafeEqual(expected, candidate)) {
|
|
107
|
+
return counter + i;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return -1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verify a user-supplied code against a secret, allowing ±`window` steps of
|
|
115
|
+
* clock drift (default ±1 = ±30s). Boolean convenience over matchCounter().
|
|
116
|
+
* NOTE: this does NOT prevent replay on its own — callers that need replay
|
|
117
|
+
* protection must use matchCounter() and persist the accepted counter.
|
|
118
|
+
*/
|
|
119
|
+
function verifyTotp(secretBase32, code, opts = {}) {
|
|
120
|
+
return matchCounter(secretBase32, code, opts) >= 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** otpauth:// provisioning URI for authenticator-app QR codes / manual entry. */
|
|
124
|
+
function provisioningUri(secretBase32, label, issuer) {
|
|
125
|
+
const fullLabel = issuer ? `${issuer}:${label}` : label;
|
|
126
|
+
const params = new URLSearchParams({ secret: secretBase32, algorithm: 'SHA1', digits: '6', period: '30' });
|
|
127
|
+
if (issuer) params.set('issuer', issuer);
|
|
128
|
+
return `otpauth://totp/${encodeURIComponent(fullLabel)}?${params.toString()}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
base32Encode,
|
|
133
|
+
base32Decode,
|
|
134
|
+
generateSecret,
|
|
135
|
+
hotp,
|
|
136
|
+
totp,
|
|
137
|
+
matchCounter,
|
|
138
|
+
verifyTotp,
|
|
139
|
+
provisioningUri
|
|
140
|
+
};
|