myphoneserver 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -5,13 +5,15 @@ relay cannot create a valid management request or read its code/log payloads.
5
5
  A compatible phone APK and relay `owner/call` transport are required. The old remote
6
6
  MCP connection alone cannot manage an upgraded phone.
7
7
 
8
- ## Connect (one QR scan)
8
+ ## Connect (QR or numeric code)
9
9
 
10
10
  ```sh
11
11
  npx -y myphoneserver connect
12
12
  ```
13
13
 
14
- The PC opens a QR page. On the phone: MyPhoneServer → **PC 연결** → scan the QR → **연결**. The phone enrolls this
14
+ The PC opens a page with a QR and an eight-digit connection code. If scanning is unavailable, enter that code in the phone's **PC 연결 → 번호로 연결**. Compare the separate eight-digit confirmation number on the phone and this PC; confirm only if they match. The connection code expires after ten minutes and only locates a session: it does not authorize a PC.
15
+
16
+ For QR, on the phone: MyPhoneServer → **PC 연결** → scan the QR → **연결**. The phone enrolls this
15
17
  PC's public key from the QR (a local, optical channel) and returns its pairing and transport details encrypted with
16
18
  the QR's one-time secret through a relay mailbox (`/__pocket/pair/<id>`, 10 minutes, handed over once). The relay can
17
19
  neither read nor forge that reply. The command then registers the `myphoneserver` stdio MCP server with Claude Code
@@ -64,4 +66,4 @@ failure, inspect status before sending another deployment; the adapter does not
64
66
  silently retry a command that may already have executed.
65
67
 
66
68
  This protects the management channel, not the ordinary public website traffic.
67
- See [security scope and remaining limits](../../docs/security.md).
69
+ See [security scope and remaining limits](https://github.com/enabletech-ai/pocketserver/blob/main/docs/security.md).
package/numeric.mjs ADDED
@@ -0,0 +1,22 @@
1
+ // An eight-digit rendezvous locates the session; it is NOT an authorization secret.
2
+ // Commit before the phone's fresh contribution; compare the derived number on both screens.
3
+ import {randomInt,randomBytes,createHash,createPublicKey,createPrivateKey,diffieHellman,hkdfSync} from 'node:crypto';
4
+ import {keyPair} from './crypto.mjs';
5
+ const DOMAIN='pocket-number-v1';
6
+ const hash=v=>createHash('sha256').update(v).digest();
7
+ const text=(code,r)=>[DOMAIN,code,r.owner,r.key,r.nonce,r.name].join('\n');
8
+ export const commitment=(code,r)=>hash(text(code,r)).toString('base64url');
9
+ export function numericSession(owner,name){
10
+ const code=String(randomInt(100000000)).padStart(8,'0'),ephemeral=keyPair();
11
+ const reveal={owner,key:ephemeral.publicKey,nonce:randomBytes(32).toString('base64url'),name:name.slice(0,40).replace(/[\r\n]/g,' ')};
12
+ return {code,reveal,commit:commitment(code,reveal),finish(hello){
13
+ if(!/^[A-Za-z0-9_-]{43}$/.test(hello?.nonce||''))throw Error('Invalid phone nonce');
14
+ const publicKey=createPublicKey({key:Buffer.from(hello.key,'base64'),type:'spki',format:'der'});
15
+ if(publicKey.asymmetricKeyDetails?.namedCurve!=='prime256v1')throw Error('Invalid phone curve');
16
+ const transcript=text(code,reveal)+'\n'+hello.key+'\n'+hello.nonce,digest=hash(transcript);
17
+ const shared=diffieHellman({privateKey:createPrivateKey(ephemeral.privateKey),publicKey});
18
+ const secret=Buffer.from(hkdfSync('sha256',shared,digest,DOMAIN,32));
19
+ const comparison=String(hash(Buffer.concat([secret,Buffer.from(transcript)])).readUInt32BE()%100000000).padStart(8,'0');
20
+ return {id:digest.toString('base64url'),secret:secret.toString('base64url'),comparison};
21
+ }};
22
+ }
package/package.json CHANGED
@@ -1,11 +1,27 @@
1
1
  {
2
2
  "name": "myphoneserver",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Connect Claude Code or Codex to your MyPhoneServer phone and deploy apps to it.",
5
5
  "type": "module",
6
- "bin": { "myphoneserver": "client.mjs" },
7
- "files": ["client.mjs", "crypto.mjs", "pair.mjs", "brand.mjs", "lib/", "README.md"],
8
- "engines": { "node": ">=20" },
9
- "dependencies": { "qrcode": "1.5.4" },
10
- "scripts": { "prepack": "mkdir -p lib && cp ../../relay/plan.mjs ../../relay/safety.mjs lib/" }
6
+ "bin": {
7
+ "myphoneserver": "client.mjs"
8
+ },
9
+ "files": [
10
+ "client.mjs",
11
+ "crypto.mjs",
12
+ "pair.mjs",
13
+ "numeric.mjs",
14
+ "brand.mjs",
15
+ "lib/",
16
+ "README.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "dependencies": {
22
+ "qrcode": "1.5.4"
23
+ },
24
+ "scripts": {
25
+ "prepack": "mkdir -p lib && cp ../../relay/plan.mjs ../../relay/safety.mjs lib/"
26
+ }
11
27
  }
package/pair.mjs CHANGED
@@ -7,6 +7,7 @@ import http from 'node:http';
7
7
  import {spawn,spawnSync} from 'node:child_process';
8
8
  import {fileURLToPath} from 'node:url';
9
9
  import QRCode from 'qrcode';
10
+ import {numericSession} from './numeric.mjs';
10
11
  import {logoSvg,brandCss} from './brand.mjs';
11
12
  import {keyPair,fingerprint,pairSession,openPair} from './crypto.mjs';
12
13
 
@@ -63,41 +64,75 @@ function openBrowser(url){
63
64
  const [bin,args]=process.platform==='darwin'?['open',[url]]:process.platform==='win32'?['cmd',['/c','start','',url]]:['xdg-open',[url]];
64
65
  try{spawn(bin,args,{stdio:'ignore',detached:true}).unref();}catch{}
65
66
  }
66
- const page=(svg)=>`<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PC 연결 · MyPhoneServer</title>
67
+ const page=(svg,code)=>`<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PC 연결 · MyPhoneServer</title>
67
68
  <style>${brandCss}*{box-sizing:border-box}body{margin:0;background:var(--brand-background);color:var(--brand-ink);font:14px/1.6 -apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo',sans-serif;letter-spacing:-.25px}header{border-bottom:1px solid var(--brand-line);background:white;padding:20px;display:flex;justify-content:center}header svg{width:205px;height:51px}main{width:420px;max-width:calc(100% - 32px);margin:38px auto;background:white;border:1px solid var(--brand-line);border-radius:14px;padding:30px;text-align:center}h1{font-size:25px;letter-spacing:-1px;margin:0 0 10px}p{font-size:12px;color:var(--brand-muted);margin:0 0 22px}.qr{padding:14px;background:white;border:1px solid var(--brand-line);border-radius:12px;display:inline-flex}.qr svg{width:250px;height:250px;max-width:100%}#s{font-size:12px;color:var(--brand-blue);padding:12px;background:var(--brand-soft);border-radius:7px;margin:22px 0 0}@media(max-width:480px){main{margin:25px auto;padding:25px 20px}.qr svg{width:235px;height:235px}}</style>
68
- <header>${logoSvg}</header><main><h1>폰과 연결하기</h1><p>폰 앱에서 <b>PC 연결</b>을 누르고 QR을 찍어 주세요.</p><div class="qr">${svg}</div><p id="s" role="status">기다리는 중…</p></main>
69
- <script>setInterval(async()=>{try{const r=await (await fetch('/status')).json();document.getElementById('s').textContent=r.message;if(r.state!=='waiting')document.querySelector('.qr svg').style.opacity=.15;}catch{}},1500)</script></html>`;
69
+ <header>${logoSvg}</header><main><h1>폰과 연결하기</h1><p>폰 앱의 <b>PC 연결</b>에서 QR을 찍거나 아래 번호를 입력하세요.</p><h2 id="code">${code?code.slice(0,4)+' '+code.slice(4):'번호 연결을 준비하지 못했어요. 다시 실행해 주세요.'}</h2><p id="comparison"></p><div class="qr">${svg}</div><p id="s" role="status">기다리는 중…</p></main>
70
+ <script>setInterval(async()=>{try{const r=await (await fetch('/status')).json();document.getElementById('s').textContent=r.message;document.getElementById('comparison').textContent=r.comparison?'확인번호: '+r.comparison.slice(0,4)+' '+r.comparison.slice(4)+' · 폰의 번호와 같을 때만 연결하세요.':'';if(r.state!=='waiting')document.querySelector('.qr svg').style.opacity=.15;}catch{}},1500)</script></html>`;
71
+
72
+ // Legacy installations keep their relay during account sign-in. Reuse only a locally
73
+ // trusted transport for the exact same endpoint; never obtain its key from the relay.
74
+ export function pairTransport(reply,previous,fallbackKey){
75
+ const mcpUrl=reply.mcpUrl||(reply.relayOrigin&&reply.relayOrigin+'/mcp');
76
+ if(!mcpUrl)throw Error('Phone transport missing');
77
+ const parsed=new URL(mcpUrl);
78
+ if(parsed.protocol!=='https:' || parsed.username || parsed.password)throw Error('Invalid phone transport');
79
+ const mcpKey=reply.mcpKey||fallbackKey||(previous?.mcpUrl===mcpUrl?previous.mcpKey:null);
80
+ if(!mcpKey)throw Error('Phone account transport missing');
81
+ return {mcpUrl,mcpKey};
82
+ }
70
83
 
71
84
  // Background worker: shows the QR page and waits for the phone (up to 10 minutes).
72
85
  async function worker(home){
73
86
  const owner=ownerKey(home),session=pairSession();
87
+ let previous;
88
+ try{const file=path.join(home,'connection.json'),stat=fs.lstatSync(file);
89
+ if(stat.isFile() && !stat.isSymbolicLink() && !(stat.mode&0o077))previous=JSON.parse(fs.readFileSync(file,'utf8'));
90
+ }catch{}
74
91
  const qr=`myphoneserver:pair?v=1&id=${session.id}&s=${session.secret}&k=${encodeURIComponent(owner.publicKey)}&n=${encodeURIComponent(os.hostname().replace(/\.local$/,'').slice(0,40))}`;
75
92
  const svg=await QRCode.toString(qr,{type:'svg',margin:1,errorCorrectionLevel:'M'});
76
- let status={state:'waiting',message:'기다리는 중…'};
93
+ let numeric;
94
+ if(!process.env.POCKET_TEST)for(let i=0;i<4;i++){
95
+ const candidate=numericSession(owner.publicKey,os.hostname().replace(/\.local$/,'').slice(0,40));
96
+ try{const r=await fetch(`${RELAY}/__pocket/number/${candidate.code}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({commit:candidate.commit}),signal:AbortSignal.timeout(10000)});
97
+ if(r.status===409)continue;if(r.ok)numeric=candidate;}catch{}
98
+ break;
99
+ }
100
+ let numberPair;
101
+ let status={state:'waiting',message:'QR을 찍거나 폰에서 연결 번호를 입력해 주세요.',...(numeric?{code:numeric.code}:{})};
77
102
  const server=http.createServer((req,res)=>{
78
103
  if(req.headers.host!==`127.0.0.1:${server.address().port}`){res.writeHead(403).end();return;} // no DNS rebinding
79
104
  if(req.url==='/status'){res.writeHead(200,{'Content-Type':'application/json','Cache-Control':'no-store'}).end(JSON.stringify(status));return;}
80
- res.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}).end(page(svg));
105
+ res.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}).end(page(svg,numeric?.code));
81
106
  });
82
107
  await new Promise(r=>server.listen(0,'127.0.0.1',r));
83
108
  const url=`http://127.0.0.1:${server.address().port}/`;
84
109
  // POCKET_TEST: automated pairing test with a temporary home; no browser, no client registration.
85
110
  // The QR text also goes to the command output so cloud sessions (no local browser) can show it in the chat.
86
- setState(home,{state:'waiting',url,qr});if(!process.env.POCKET_TEST)openBrowser(url);
111
+ setState(home,{...status,url,qr});if(!process.env.POCKET_TEST)openBrowser(url);
87
112
  const finish=(state,message)=>{status={state,message};setState(home,{state,url,message});setTimeout(()=>process.exit(0),5000);};
88
113
  const until=Date.now()+WAIT_MS;
89
114
  while(Date.now()<until){
90
115
  await new Promise(r=>setTimeout(r,2000));
91
- let response;try{response=await fetch(`${RELAY}/__pocket/pair/${session.id}`,{redirect:'error',signal:AbortSignal.timeout(10000)});}catch{continue;}
116
+ if(numeric && !numberPair){
117
+ try{const hello=await fetch(`${RELAY}/__pocket/number/${numeric.code}/hello`,{signal:AbortSignal.timeout(10000)});
118
+ if(hello.ok){numberPair=numeric.finish(await hello.json());
119
+ const reveal=await fetch(`${RELAY}/__pocket/number/${numeric.code}/reveal`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(numeric.reveal),signal:AbortSignal.timeout(10000)});
120
+ if(!reveal.ok)throw Error('reveal failed');
121
+ status={...status,comparison:numberPair.comparison,message:'폰의 확인번호와 같을 때만 폰에서 연결을 누르세요.'};setState(home,{...status,url,qr});
122
+ }
123
+ }catch{return finish('error','번호 연결을 확인하지 못했어요. 다시 연결해 주세요.');}
124
+ }
125
+ let response,selected=session;
126
+ if(numberPair){try{response=await fetch(`${RELAY}/__pocket/pair/${numberPair.id}`,{signal:AbortSignal.timeout(10000)});if(response.ok)selected=numberPair;}catch{}}
127
+ if(!response?.ok)try{response=await fetch(`${RELAY}/__pocket/pair/${session.id}`,{redirect:'error',signal:AbortSignal.timeout(10000)});}catch{continue;}
92
128
  if(response.status===404)continue;
93
129
  if(!response.ok)return finish('error','연결하지 못했어요. 다시 시도해 주세요.');
94
130
  let reply;
95
- try{reply=openPair(session.id,session.secret,await response.json());}catch{return finish('error','연결하지 못했어요. 다시 시도해 주세요.');}
131
+ try{reply=openPair(selected.id,selected.secret,await response.json());}catch{return finish('error','연결하지 못했어요. 다시 시도해 주세요.');}
96
132
  if(reply.version!==1 || reply.pairing?.version!==1 || reply.pairing.ownerFingerprint!==fingerprint(owner.publicKey))return finish('error','다른 PC의 연결 정보예요. 다시 시도해 주세요.');
97
- const mcpUrl=reply.mcpUrl||(reply.relayOrigin&&reply.relayOrigin+'/mcp'),mcpKey=reply.mcpKey||process.env.POCKET_MCP_KEY;
98
- if(!mcpUrl || !mcpKey || new URL(mcpUrl).protocol!=='https:')return finish('error','폰 앱에서 Google 로그인을 먼저 해 주세요.');
133
+ let config;try{config=pairTransport(reply,previous,process.env.POCKET_MCP_KEY);}catch{return finish('error','폰의 관리 연결 정보가 없어요. 계정 연결을 완료하거나 기존 PC에서 다시 연결해 주세요.');}
99
134
  writePrivate(home,'phone.json',JSON.stringify(reply.pairing));
100
- writePrivate(home,'connection.json',JSON.stringify({mcpUrl,mcpKey}));
135
+ writePrivate(home,'connection.json',JSON.stringify(config));
101
136
  const clients=process.env.POCKET_TEST?[]:registerClients(home);
102
137
  return finish('done',`연결됐어요!${clients.length?` ${clients.join('·')}에서 바로 쓸 수 있어요.`:''} 이 창은 닫아도 돼요.`);
103
138
  }
@@ -109,11 +144,12 @@ export async function connect(home,args){
109
144
  if(args.includes('--worker'))return worker(home);
110
145
  try{fs.unlinkSync(stateFile(home));}catch{}
111
146
  const child=spawn(process.execPath,[script,'connect','--worker'],{detached:true,stdio:'ignore',env:process.env});child.unref();
112
- let shown=false;
147
+ let shown=false,comparisonShown=false;
113
148
  for(const until=Date.now()+90000;Date.now()<until;){
114
149
  await new Promise(r=>setTimeout(r,1000));
115
150
  const s=readState(home);if(!s)continue;
116
- if(!shown && s.url){console.log(`${await QRCode.toString(s.qr,{type:'utf8',margin:2,errorCorrectionLevel:'M'})}\n폰의 MyPhoneServer 앱에서 [PC 연결]을 누르고 위 QR을 찍어 주세요. (이 PC의 브라우저에도 열었어요: ${s.url})`);shown=true;}
151
+ if(!shown && s.url){console.log(`${await QRCode.toString(s.qr,{type:'utf8',margin:2,errorCorrectionLevel:'M'})}\n폰의 MyPhoneServer 앱에서 [PC 연결]을 누르고 위 QR을 찍어 주세요. (이 PC의 브라우저에도 열었어요: ${s.url})`);shown=true;if(s.code)console.log('연결 번호: '+s.code.slice(0,4)+' '+s.code.slice(4));}
152
+ if(s.comparison && !comparisonShown){console.log('확인번호: '+s.comparison.slice(0,4)+' '+s.comparison.slice(4)+' · 폰과 같을 때만 연결하세요.');comparisonShown=true;}
117
153
  if(s.state==='done'){console.log(s.message);return;}
118
154
  if(s.state!=='waiting'){console.log(s.message);process.exitCode=1;return;}
119
155
  }