feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency.
|
|
|
|
|
//
|
|
|
|
|
// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps
|
|
|
|
|
// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors
|
|
|
|
|
// in test/auth/totp.test.js.
|
|
|
|
|
//
|
|
|
|
|
// Defaults match every mainstream authenticator app (Google Authenticator,
|
|
|
|
|
// Authy, 1Password): SHA-1, 6 digits, 30-second step.
|
|
|
|
|
|
|
|
|
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
|
|
|
|
|
|
|
const DIGITS = 6;
|
|
|
|
|
const STEP_SECONDS = 30;
|
|
|
|
|
const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
|
|
|
|
|
|
|
|
// ── base32 (RFC 4648, no padding) ──────────────────────────────────────────
|
|
|
|
|
export function base32Encode(buf) {
|
|
|
|
|
let bits = 0, value = 0, out = '';
|
|
|
|
|
for (const byte of buf) {
|
|
|
|
|
value = (value << 8) | byte;
|
|
|
|
|
bits += 8;
|
|
|
|
|
while (bits >= 5) {
|
|
|
|
|
out += RFC4648_B32[(value >>> (bits - 5)) & 31];
|
|
|
|
|
bits -= 5;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31];
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function base32Decode(str) {
|
|
|
|
|
const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, '');
|
|
|
|
|
let bits = 0, value = 0;
|
|
|
|
|
const out = [];
|
|
|
|
|
for (const ch of clean) {
|
|
|
|
|
const idx = RFC4648_B32.indexOf(ch);
|
|
|
|
|
if (idx === -1) continue; // skip stray chars
|
|
|
|
|
value = (value << 5) | idx;
|
|
|
|
|
bits += 5;
|
|
|
|
|
if (bits >= 8) {
|
|
|
|
|
out.push((value >>> (bits - 8)) & 0xff);
|
|
|
|
|
bits -= 8;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Buffer.from(out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended
|
|
|
|
|
// SHA-1 key length).
|
|
|
|
|
export function generateSecret() {
|
|
|
|
|
return base32Encode(randomBytes(20));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HOTP for a specific counter (RFC 4226).
|
|
|
|
|
function hotp(secretBuf, counter) {
|
|
|
|
|
const buf = Buffer.alloc(8);
|
|
|
|
|
// 64-bit big-endian counter.
|
|
|
|
|
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
|
|
|
|
buf.writeUInt32BE(counter >>> 0, 4);
|
|
|
|
|
const hmac = createHmac('sha1', secretBuf).update(buf).digest();
|
|
|
|
|
const offset = hmac[hmac.length - 1] & 0x0f;
|
|
|
|
|
const code = ((hmac[offset] & 0x7f) << 24)
|
|
|
|
|
| ((hmac[offset + 1] & 0xff) << 16)
|
|
|
|
|
| ((hmac[offset + 2] & 0xff) << 8)
|
|
|
|
|
| (hmac[offset + 3] & 0xff);
|
|
|
|
|
return String(code % (10 ** DIGITS)).padStart(DIGITS, '0');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The TOTP code for a given time (defaults to now).
|
|
|
|
|
export function generateToken(base32Secret, atMs = Date.now()) {
|
|
|
|
|
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
|
|
|
|
return hotp(base32Decode(base32Secret), counter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify a user-supplied code, allowing ±`window` steps of clock drift
|
|
|
|
|
// (default ±1 = 90s total tolerance). Constant-time compare per candidate.
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
//
|
|
|
|
|
// Returns the matched counter on success (so callers can persist it for
|
|
|
|
|
// replay protection — RFC 6238 §5.2), or null on failure. Boolean truthiness
|
|
|
|
|
// still works for the common case (`if (verifyToken(...))`).
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) {
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
if (!base32Secret || !token) return null;
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
const cleaned = String(token).replace(/\s+/g, '');
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
if (!/^\d{6}$/.test(cleaned)) return null;
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
const secretBuf = base32Decode(base32Secret);
|
|
|
|
|
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
|
|
|
|
const want = Buffer.from(cleaned);
|
|
|
|
|
for (let w = -window; w <= window; w++) {
|
|
|
|
|
const candidate = Buffer.from(hotp(secretBuf, counter + w));
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
if (candidate.length === want.length && timingSafeEqual(candidate, want)) return counter + w;
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
}
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
return null;
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The otpauth:// URI an authenticator app scans. label/issuer show in the app.
|
|
|
|
|
export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') {
|
|
|
|
|
const label = encodeURIComponent(`${issuer}:${accountName}`);
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
secret: base32Secret,
|
|
|
|
|
issuer,
|
|
|
|
|
algorithm: 'SHA1',
|
|
|
|
|
digits: String(DIGITS),
|
|
|
|
|
period: String(STEP_SECONDS),
|
|
|
|
|
});
|
|
|
|
|
return `otpauth://totp/${label}?${params.toString()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate N human-friendly one-time recovery codes (raw form). Caller hashes
|
|
|
|
|
// them before storage and shows the raw set to the user exactly once.
|
|
|
|
|
export function generateRecoveryCodes(n = 10) {
|
|
|
|
|
const codes = [];
|
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
|
|
|
// 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5".
|
|
|
|
|
const hex = randomBytes(5).toString('hex');
|
|
|
|
|
codes.push(hex.slice(0, 5) + '-' + hex.slice(5));
|
|
|
|
|
}
|
|
|
|
|
return codes;
|
|
|
|
|
}
|